Material Symbols 아이콘 전환 + 로고 이미지 적용 + 테이블링 이름 유사도 체크
- 전체 인라인 SVG를 Google Material Symbols Rounded로 교체 - Icon 컴포넌트 추가, cuisine-icons 매핑 리팩토링 - Tasteby 핀 로고 이미지 적용 (라이트/다크 버전) - 테이블링/캐치테이블 이름 유사도 체크 및 리셋 API 추가 - 어드민 페이지 리셋 버튼 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@@ -198,10 +198,18 @@ public class RestaurantController {
|
|||||||
if (!results.isEmpty()) {
|
if (!results.isEmpty()) {
|
||||||
String url = String.valueOf(results.get(0).get("url"));
|
String url = String.valueOf(results.get(0).get("url"));
|
||||||
String title = String.valueOf(results.get(0).get("title"));
|
String title = String.valueOf(results.get(0).get("title"));
|
||||||
restaurantService.update(r.getId(), Map.of("tabling_url", url));
|
if (isNameSimilar(r.getName(), title)) {
|
||||||
linked++;
|
restaurantService.update(r.getId(), Map.of("tabling_url", url));
|
||||||
emit(emitter, Map.of("type", "done", "current", i + 1,
|
linked++;
|
||||||
"name", r.getName(), "url", url, "title", title));
|
emit(emitter, Map.of("type", "done", "current", i + 1,
|
||||||
|
"name", r.getName(), "url", url, "title", title));
|
||||||
|
} else {
|
||||||
|
restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
|
||||||
|
notFound++;
|
||||||
|
log.info("[TABLING] Name mismatch: '{}' vs '{}', skipping", r.getName(), title);
|
||||||
|
emit(emitter, Map.of("type", "notfound", "current", i + 1,
|
||||||
|
"name", r.getName(), "reason", "이름 불일치: " + title));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
|
restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
|
||||||
notFound++;
|
notFound++;
|
||||||
@@ -246,6 +254,23 @@ public class RestaurantController {
|
|||||||
return Map.of("ok", true);
|
return Map.of("ok", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 테이블링/캐치테이블 매핑 초기화 */
|
||||||
|
@DeleteMapping("/reset-tabling")
|
||||||
|
public Map<String, Object> resetTabling() {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
restaurantService.resetTablingUrls();
|
||||||
|
cache.flush();
|
||||||
|
return Map.of("ok", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/reset-catchtable")
|
||||||
|
public Map<String, Object> resetCatchtable() {
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
|
restaurantService.resetCatchtableUrls();
|
||||||
|
cache.flush();
|
||||||
|
return Map.of("ok", true);
|
||||||
|
}
|
||||||
|
|
||||||
/** 단건 캐치테이블 URL 검색 */
|
/** 단건 캐치테이블 URL 검색 */
|
||||||
@GetMapping("/{id}/catchtable-search")
|
@GetMapping("/{id}/catchtable-search")
|
||||||
public List<Map<String, Object>> catchtableSearch(@PathVariable String id) {
|
public List<Map<String, Object>> catchtableSearch(@PathVariable String id) {
|
||||||
@@ -311,10 +336,18 @@ public class RestaurantController {
|
|||||||
if (!results.isEmpty()) {
|
if (!results.isEmpty()) {
|
||||||
String url = String.valueOf(results.get(0).get("url"));
|
String url = String.valueOf(results.get(0).get("url"));
|
||||||
String title = String.valueOf(results.get(0).get("title"));
|
String title = String.valueOf(results.get(0).get("title"));
|
||||||
restaurantService.update(r.getId(), Map.of("catchtable_url", url));
|
if (isNameSimilar(r.getName(), title)) {
|
||||||
linked++;
|
restaurantService.update(r.getId(), Map.of("catchtable_url", url));
|
||||||
emit(emitter, Map.of("type", "done", "current", i + 1,
|
linked++;
|
||||||
"name", r.getName(), "url", url, "title", title));
|
emit(emitter, Map.of("type", "done", "current", i + 1,
|
||||||
|
"name", r.getName(), "url", url, "title", title));
|
||||||
|
} else {
|
||||||
|
restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
|
||||||
|
notFound++;
|
||||||
|
log.info("[CATCHTABLE] Name mismatch: '{}' vs '{}', skipping", r.getName(), title);
|
||||||
|
emit(emitter, Map.of("type", "notfound", "current", i + 1,
|
||||||
|
"name", r.getName(), "reason", "이름 불일치: " + title));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
|
restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
|
||||||
notFound++;
|
notFound++;
|
||||||
@@ -489,6 +522,31 @@ public class RestaurantController {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 식당 이름과 검색 결과 제목의 유사도 검사.
|
||||||
|
* 한쪽 이름이 다른쪽에 포함되거나, 공통 글자 비율이 40% 이상이면 유사하다고 판단.
|
||||||
|
*/
|
||||||
|
private boolean isNameSimilar(String restaurantName, String resultTitle) {
|
||||||
|
String a = normalize(restaurantName);
|
||||||
|
String b = normalize(resultTitle);
|
||||||
|
if (a.isEmpty() || b.isEmpty()) return false;
|
||||||
|
|
||||||
|
// 포함 관계 체크
|
||||||
|
if (a.contains(b) || b.contains(a)) return true;
|
||||||
|
|
||||||
|
// 공통 문자 비율 (Jaccard-like)
|
||||||
|
var setA = a.chars().boxed().collect(java.util.stream.Collectors.toSet());
|
||||||
|
var setB = b.chars().boxed().collect(java.util.stream.Collectors.toSet());
|
||||||
|
long common = setA.stream().filter(setB::contains).count();
|
||||||
|
double ratio = (double) common / Math.max(setA.size(), setB.size());
|
||||||
|
return ratio >= 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalize(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return s.replaceAll("[\\s·\\-_()()\\[\\]【】]", "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
private void emit(SseEmitter emitter, Map<String, Object> data) {
|
private void emit(SseEmitter emitter, Map<String, Object> data) {
|
||||||
try {
|
try {
|
||||||
emitter.send(SseEmitter.event().data(objectMapper.writeValueAsString(data)));
|
emitter.send(SseEmitter.event().data(objectMapper.writeValueAsString(data)));
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ public interface RestaurantMapper {
|
|||||||
|
|
||||||
List<Restaurant> findWithoutCatchtable();
|
List<Restaurant> findWithoutCatchtable();
|
||||||
|
|
||||||
|
void resetTablingUrls();
|
||||||
|
|
||||||
|
void resetCatchtableUrls();
|
||||||
|
|
||||||
List<Map<String, Object>> findForRemapCuisine();
|
List<Map<String, Object>> findForRemapCuisine();
|
||||||
|
|
||||||
List<Map<String, Object>> findForRemapFoods();
|
List<Map<String, Object>> findForRemapFoods();
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ public class RestaurantService {
|
|||||||
return mapper.findWithoutCatchtable();
|
return mapper.findWithoutCatchtable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void resetTablingUrls() {
|
||||||
|
mapper.resetTablingUrls();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void resetCatchtableUrls() {
|
||||||
|
mapper.resetCatchtableUrls();
|
||||||
|
}
|
||||||
|
|
||||||
public Restaurant findById(String id) {
|
public Restaurant findById(String id) {
|
||||||
Restaurant restaurant = mapper.findById(id);
|
Restaurant restaurant = mapper.findById(id);
|
||||||
if (restaurant == null) return null;
|
if (restaurant == null) return null;
|
||||||
|
|||||||
@@ -239,6 +239,14 @@
|
|||||||
ORDER BY r.name
|
ORDER BY r.name
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<update id="resetTablingUrls">
|
||||||
|
UPDATE restaurants SET tabling_url = NULL WHERE tabling_url IS NOT NULL
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="resetCatchtableUrls">
|
||||||
|
UPDATE restaurants SET catchtable_url = NULL WHERE catchtable_url IS NOT NULL
|
||||||
|
</update>
|
||||||
|
|
||||||
<!-- ===== Remap operations ===== -->
|
<!-- ===== Remap operations ===== -->
|
||||||
|
|
||||||
<update id="updateCuisineType">
|
<update id="updateCuisineType">
|
||||||
|
|||||||
16
frontend/dev-restart.sh
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build + restart dev server (standalone mode)
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "▶ Building..."
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo "▶ Copying static files to standalone..."
|
||||||
|
cp -r .next/static .next/standalone/.next/static
|
||||||
|
cp -r public .next/standalone/public 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "▶ Restarting PM2..."
|
||||||
|
pm2 restart tasteby-web
|
||||||
|
|
||||||
|
echo "✅ Done — http://localhost:3001"
|
||||||
BIN
frontend/public/logo-120h.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
frontend/public/logo-200h.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
frontend/public/logo-80h.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/public/logo-dark-120h.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
frontend/public/logo-dark-80h.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/public/logo-dark.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
frontend/public/logo.png
Normal file
|
After Width: | Height: | Size: 947 KiB |
@@ -41,33 +41,37 @@ export default function AdminPage() {
|
|||||||
const isAdmin = user?.is_admin === true;
|
const isAdmin = user?.is_admin === true;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className="min-h-screen bg-gray-50 flex items-center justify-center text-gray-500">로딩 중...</div>;
|
return <div className="min-h-screen bg-background flex items-center justify-center text-gray-500">로딩 중...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-gray-600 mb-4">로그인이 필요합니다</p>
|
<p className="text-gray-600 mb-4">로그인이 필요합니다</p>
|
||||||
<a href="/" className="text-blue-600 hover:underline">메인으로 돌아가기</a>
|
<a href="/" className="text-brand-600 hover:underline">메인으로 돌아가기</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 text-gray-900">
|
<div className="min-h-screen bg-background text-gray-900">
|
||||||
<header className="bg-white border-b px-6 py-4">
|
<header className="bg-surface border-b border-brand-100 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">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-xl font-bold">Tasteby Admin</h1>
|
<picture>
|
||||||
|
<source srcSet="/logo-dark-80h.png" media="(prefers-color-scheme: dark)" />
|
||||||
|
<img src="/logo-80h.png" alt="Tasteby" className="h-7" />
|
||||||
|
</picture>
|
||||||
|
<span className="text-xl font-bold text-gray-500">Admin</span>
|
||||||
{!isAdmin && (
|
{!isAdmin && (
|
||||||
<span className="px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded text-xs font-medium">읽기 전용</span>
|
<span className="px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded text-xs font-medium">읽기 전용</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{isAdmin && <CacheFlushButton />}
|
{isAdmin && <CacheFlushButton />}
|
||||||
<a href="/" className="text-sm text-blue-600 hover:underline">
|
<a href="/" className="text-sm text-brand-600 hover:underline">
|
||||||
← 메인으로
|
← 메인으로
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,8 +83,8 @@ export default function AdminPage() {
|
|||||||
onClick={() => setTab(t)}
|
onClick={() => setTab(t)}
|
||||||
className={`px-4 py-2 text-sm rounded-t font-medium ${
|
className={`px-4 py-2 text-sm rounded-t font-medium ${
|
||||||
tab === t
|
tab === t
|
||||||
? "bg-blue-600 text-white"
|
? "bg-brand-600 text-white"
|
||||||
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
: "bg-brand-50 text-brand-700 hover:bg-brand-100"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t === "channels" ? "채널 관리" : t === "videos" ? "영상 관리" : t === "restaurants" ? "식당 관리" : t === "users" ? "유저 관리" : "데몬 설정"}
|
{t === "channels" ? "채널 관리" : t === "videos" ? "영상 관리" : t === "restaurants" ? "식당 관리" : t === "users" ? "유저 관리" : "데몬 설정"}
|
||||||
@@ -171,40 +175,40 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{isAdmin && <div className="bg-white rounded-lg shadow p-4 mb-6">
|
{isAdmin && <div className="bg-surface 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
|
||||||
placeholder="YouTube Channel ID"
|
placeholder="YouTube Channel ID"
|
||||||
value={newId}
|
value={newId}
|
||||||
onChange={(e) => setNewId(e.target.value)}
|
onChange={(e) => setNewId(e.target.value)}
|
||||||
className="border rounded px-3 py-2 flex-1 text-sm bg-white text-gray-900"
|
className="border rounded px-3 py-2 flex-1 text-sm bg-surface text-gray-900"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
placeholder="채널 이름"
|
placeholder="채널 이름"
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
className="border rounded px-3 py-2 flex-1 text-sm bg-white text-gray-900"
|
className="border rounded px-3 py-2 flex-1 text-sm bg-surface text-gray-900"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
placeholder="제목 필터 (선택)"
|
placeholder="제목 필터 (선택)"
|
||||||
value={newFilter}
|
value={newFilter}
|
||||||
onChange={(e) => setNewFilter(e.target.value)}
|
onChange={(e) => setNewFilter(e.target.value)}
|
||||||
className="border rounded px-3 py-2 w-40 text-sm bg-white text-gray-900"
|
className="border rounded px-3 py-2 w-40 text-sm bg-surface text-gray-900"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700 disabled:opacity-50"
|
className="bg-brand-600 text-white px-4 py-2 rounded text-sm hover:bg-brand-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
추가
|
추가
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow">
|
<div className="bg-surface rounded-lg shadow">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold">
|
<thead className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm font-semibold">
|
||||||
<tr>
|
<tr>
|
||||||
<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>
|
||||||
@@ -219,14 +223,14 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{channels.map((ch) => (
|
{channels.map((ch) => (
|
||||||
<tr key={ch.id} className="border-b hover:bg-gray-50">
|
<tr key={ch.id} className="border-b hover:bg-brand-50/50">
|
||||||
<td className="px-4 py-3 font-medium">{ch.channel_name}</td>
|
<td className="px-4 py-3 font-medium">{ch.channel_name}</td>
|
||||||
<td className="px-4 py-3 text-gray-500 font-mono text-xs">
|
<td className="px-4 py-3 text-gray-500 font-mono text-xs">
|
||||||
{ch.channel_id}
|
{ch.channel_id}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm">
|
<td className="px-4 py-3 text-sm">
|
||||||
{ch.title_filter ? (
|
{ch.title_filter ? (
|
||||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs">
|
<span className="px-2 py-0.5 bg-brand-50 text-brand-700 rounded text-xs">
|
||||||
{ch.title_filter}
|
{ch.title_filter}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -236,7 +240,7 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<td className="px-4 py-3 text-xs">
|
<td className="px-4 py-3 text-xs">
|
||||||
{editingChannel === ch.id ? (
|
{editingChannel === ch.id ? (
|
||||||
<input value={editDesc} onChange={(e) => setEditDesc(e.target.value)}
|
<input value={editDesc} onChange={(e) => setEditDesc(e.target.value)}
|
||||||
className="border rounded px-2 py-1 text-xs w-32 bg-white text-gray-900" placeholder="설명" />
|
className="border rounded px-2 py-1 text-xs w-32 bg-surface text-gray-900" placeholder="설명" />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-600 cursor-pointer" onClick={() => {
|
<span className="text-gray-600 cursor-pointer" onClick={() => {
|
||||||
if (!isAdmin) return;
|
if (!isAdmin) return;
|
||||||
@@ -248,8 +252,8 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
{editingChannel === ch.id ? (
|
{editingChannel === ch.id ? (
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<input value={editTags} onChange={(e) => setEditTags(e.target.value)}
|
<input value={editTags} onChange={(e) => setEditTags(e.target.value)}
|
||||||
className="border rounded px-2 py-1 text-xs w-40 bg-white text-gray-900" placeholder="태그 (쉼표 구분)" />
|
className="border rounded px-2 py-1 text-xs w-40 bg-surface text-gray-900" placeholder="태그 (쉼표 구분)" />
|
||||||
<button onClick={() => handleSaveChannel(ch.id)} className="text-blue-600 text-xs hover:underline">저장</button>
|
<button onClick={() => handleSaveChannel(ch.id)} className="text-brand-600 text-xs hover:underline">저장</button>
|
||||||
<button onClick={() => setEditingChannel(null)} className="text-gray-400 text-xs hover:underline">취소</button>
|
<button onClick={() => setEditingChannel(null)} className="text-gray-400 text-xs hover:underline">취소</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -262,7 +266,7 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<td className="px-4 py-3 text-center text-xs">
|
<td className="px-4 py-3 text-center text-xs">
|
||||||
{editingChannel === ch.id ? (
|
{editingChannel === ch.id ? (
|
||||||
<input type="number" value={editOrder} onChange={(e) => setEditOrder(Number(e.target.value))}
|
<input type="number" value={editOrder} onChange={(e) => setEditOrder(Number(e.target.value))}
|
||||||
className="border rounded px-2 py-1 text-xs w-14 text-center bg-white text-gray-900" min={1} />
|
className="border rounded px-2 py-1 text-xs w-14 text-center bg-surface text-gray-900" min={1} />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-500">{ch.sort_order ?? 99}</span>
|
<span className="text-gray-500">{ch.sort_order ?? 99}</span>
|
||||||
)}
|
)}
|
||||||
@@ -277,7 +281,7 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
{isAdmin && <td className="px-4 py-3 flex gap-3">
|
{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-brand-600 hover:underline text-sm"
|
||||||
>
|
>
|
||||||
스캔
|
스캔
|
||||||
</button>
|
</button>
|
||||||
@@ -738,7 +742,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
|
|
||||||
const statusColor: Record<string, string> = {
|
const statusColor: Record<string, string> = {
|
||||||
pending: "bg-yellow-100 text-yellow-800",
|
pending: "bg-yellow-100 text-yellow-800",
|
||||||
processing: "bg-blue-100 text-blue-800",
|
processing: "bg-brand-100 text-brand-800",
|
||||||
done: "bg-green-100 text-green-800",
|
done: "bg-green-100 text-green-800",
|
||||||
error: "bg-red-100 text-red-800",
|
error: "bg-red-100 text-red-800",
|
||||||
skip: "bg-gray-100 text-gray-600",
|
skip: "bg-gray-100 text-gray-600",
|
||||||
@@ -750,7 +754,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<select
|
<select
|
||||||
value={channelFilter}
|
value={channelFilter}
|
||||||
onChange={(e) => { setChannelFilter(e.target.value); setPage(0); }}
|
onChange={(e) => { setChannelFilter(e.target.value); setPage(0); }}
|
||||||
className="border rounded px-3 py-2 text-sm bg-white text-gray-900"
|
className="border rounded px-3 py-2 text-sm bg-surface text-gray-900"
|
||||||
>
|
>
|
||||||
<option value="">전체 채널</option>
|
<option value="">전체 채널</option>
|
||||||
{channels.map((ch) => (
|
{channels.map((ch) => (
|
||||||
@@ -760,7 +764,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
className="border rounded px-3 py-2 text-sm bg-white text-gray-900"
|
className="border rounded px-3 py-2 text-sm bg-surface text-gray-900"
|
||||||
>
|
>
|
||||||
<option value="">전체 상태</option>
|
<option value="">전체 상태</option>
|
||||||
<option value="pending">대기중</option>
|
<option value="pending">대기중</option>
|
||||||
@@ -776,7 +780,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
value={titleSearch}
|
value={titleSearch}
|
||||||
onChange={(e) => { setTitleSearch(e.target.value); setPage(0); }}
|
onChange={(e) => { setTitleSearch(e.target.value); setPage(0); }}
|
||||||
onKeyDown={(e) => e.key === "Escape" && setTitleSearch("")}
|
onKeyDown={(e) => e.key === "Escape" && setTitleSearch("")}
|
||||||
className="border border-r-0 rounded-l px-3 py-2 text-sm w-48 bg-white text-gray-900"
|
className="border border-r-0 rounded-l px-3 py-2 text-sm w-48 bg-surface text-gray-900"
|
||||||
/>
|
/>
|
||||||
{titleSearch ? (
|
{titleSearch ? (
|
||||||
<button
|
<button
|
||||||
@@ -880,9 +884,9 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow overflow-auto min-w-[800px]">
|
<div className="bg-surface rounded-lg shadow overflow-auto min-w-[800px]">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold">
|
<thead className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm font-semibold">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 w-8">
|
<th className="px-4 py-3 w-8">
|
||||||
<input
|
<input
|
||||||
@@ -923,7 +927,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{pagedVideos.map((v) => (
|
{pagedVideos.map((v) => (
|
||||||
<tr key={v.id} className={`border-b hover:bg-gray-50 ${selected.has(v.id) ? "bg-blue-50" : ""}`}>
|
<tr key={v.id} className={`border-b hover:bg-brand-50/50 ${selected.has(v.id) ? "bg-brand-50" : ""}`}>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -946,7 +950,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleSelectVideo(v)}
|
onClick={() => handleSelectVideo(v)}
|
||||||
className={`text-left text-sm hover:underline truncate block max-w-full ${
|
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-brand-600"
|
||||||
}`}
|
}`}
|
||||||
title={v.title}
|
title={v.title}
|
||||||
>
|
>
|
||||||
@@ -957,7 +961,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<span title="자막" className={`inline-block w-5 text-center text-xs ${v.has_transcript ? "text-green-600" : "text-gray-300"}`}>
|
<span title="자막" className={`inline-block w-5 text-center text-xs ${v.has_transcript ? "text-green-600" : "text-gray-300"}`}>
|
||||||
{v.has_transcript ? "T" : "-"}
|
{v.has_transcript ? "T" : "-"}
|
||||||
</span>
|
</span>
|
||||||
<span title="LLM 추출" className={`inline-block w-5 text-center text-xs ${v.has_llm ? "text-blue-600" : "text-gray-300"}`}>
|
<span title="LLM 추출" className={`inline-block w-5 text-center text-xs ${v.has_llm ? "text-brand-600" : "text-gray-300"}`}>
|
||||||
{v.has_llm ? "L" : "-"}
|
{v.has_llm ? "L" : "-"}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -1049,7 +1053,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
|
|
||||||
{/* 음식종류 재분류 진행 */}
|
{/* 음식종류 재분류 진행 */}
|
||||||
{remapProgress && (
|
{remapProgress && (
|
||||||
<div className="mt-4 bg-white rounded-lg shadow p-4">
|
<div className="mt-4 bg-surface rounded-lg shadow p-4">
|
||||||
<h4 className="font-semibold text-sm mb-2">
|
<h4 className="font-semibold text-sm mb-2">
|
||||||
음식종류 재분류 {remapProgress.current >= remapProgress.total ? "완료" : "진행 중"}
|
음식종류 재분류 {remapProgress.current >= remapProgress.total ? "완료" : "진행 중"}
|
||||||
</h4>
|
</h4>
|
||||||
@@ -1067,7 +1071,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
|
|
||||||
{/* 메뉴태그 재생성 진행 */}
|
{/* 메뉴태그 재생성 진행 */}
|
||||||
{foodsProgress && (
|
{foodsProgress && (
|
||||||
<div className="mt-4 bg-white rounded-lg shadow p-4">
|
<div className="mt-4 bg-surface rounded-lg shadow p-4">
|
||||||
<h4 className="font-semibold text-sm mb-2">
|
<h4 className="font-semibold text-sm mb-2">
|
||||||
메뉴태그 재생성 {foodsProgress.current >= foodsProgress.total ? "완료" : "진행 중"}
|
메뉴태그 재생성 {foodsProgress.current >= foodsProgress.total ? "완료" : "진행 중"}
|
||||||
</h4>
|
</h4>
|
||||||
@@ -1085,7 +1089,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
|
|
||||||
{/* 벡터 재생성 진행 */}
|
{/* 벡터 재생성 진행 */}
|
||||||
{vectorProgress && (
|
{vectorProgress && (
|
||||||
<div className="mt-4 bg-white rounded-lg shadow p-4">
|
<div className="mt-4 bg-surface rounded-lg shadow p-4">
|
||||||
<h4 className="font-semibold text-sm mb-2">
|
<h4 className="font-semibold text-sm mb-2">
|
||||||
벡터 재생성 {vectorProgress.phase === "done" ? "완료" : `(${vectorProgress.phase === "prepare" ? "데이터 준비" : "임베딩 저장"})`}
|
벡터 재생성 {vectorProgress.phase === "done" ? "완료" : `(${vectorProgress.phase === "prepare" ? "데이터 준비" : "임베딩 저장"})`}
|
||||||
</h4>
|
</h4>
|
||||||
@@ -1104,7 +1108,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
|
|
||||||
{/* 벌크 진행 패널 */}
|
{/* 벌크 진행 패널 */}
|
||||||
{bulkProgress && (
|
{bulkProgress && (
|
||||||
<div className="mt-4 bg-white rounded-lg shadow p-4">
|
<div className="mt-4 bg-surface rounded-lg shadow p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h4 className="font-semibold text-sm">
|
<h4 className="font-semibold text-sm">
|
||||||
{bulkProgress.label} ({bulkProgress.current}/{bulkProgress.total})
|
{bulkProgress.label} ({bulkProgress.current}/{bulkProgress.total})
|
||||||
@@ -1157,7 +1161,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<div className="mt-6 text-center text-gray-500 text-sm">로딩 중...</div>
|
<div className="mt-6 text-center text-gray-500 text-sm">로딩 중...</div>
|
||||||
)}
|
)}
|
||||||
{detail && !detailLoading && (
|
{detail && !detailLoading && (
|
||||||
<div className="mt-6 bg-white rounded-lg shadow p-4">
|
<div className="mt-6 bg-surface rounded-lg shadow p-4">
|
||||||
<div className="flex items-center justify-between mb-4 gap-2">
|
<div className="flex items-center justify-between mb-4 gap-2">
|
||||||
{editingTitle ? (
|
{editingTitle ? (
|
||||||
<div className="flex items-center gap-2 flex-1">
|
<div className="flex items-center gap-2 flex-1">
|
||||||
@@ -1178,7 +1182,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
finally { setSaving(false); }
|
finally { setSaving(false); }
|
||||||
}}
|
}}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
className="px-2 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
저장
|
저장
|
||||||
</button>
|
</button>
|
||||||
@@ -1191,7 +1195,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<h3
|
<h3
|
||||||
className={`font-semibold text-base ${isAdmin ? "cursor-pointer hover:text-blue-600" : ""}`}
|
className={`font-semibold text-base ${isAdmin ? "cursor-pointer hover:text-brand-600" : ""}`}
|
||||||
onClick={isAdmin ? () => { setEditTitle(detail.title); setEditingTitle(true); } : undefined}
|
onClick={isAdmin ? () => { setEditTitle(detail.title); setEditingTitle(true); } : undefined}
|
||||||
title={isAdmin ? "클릭하여 제목 수정" : undefined}
|
title={isAdmin ? "클릭하여 제목 수정" : undefined}
|
||||||
>
|
>
|
||||||
@@ -1290,34 +1294,34 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-gray-500">식당명 *</label>
|
<label className="text-[10px] text-gray-500">식당명 *</label>
|
||||||
<input value={manualForm.name} onChange={(e) => setManualForm(f => ({ ...f, name: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="식당 이름" />
|
<input value={manualForm.name} onChange={(e) => setManualForm(f => ({ ...f, name: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="식당 이름" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-gray-500">주소</label>
|
<label className="text-[10px] text-gray-500">주소</label>
|
||||||
<input value={manualForm.address} onChange={(e) => setManualForm(f => ({ ...f, address: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="주소 (없으면 지역)" />
|
<input value={manualForm.address} onChange={(e) => setManualForm(f => ({ ...f, address: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="주소 (없으면 지역)" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-gray-500">지역</label>
|
<label className="text-[10px] text-gray-500">지역</label>
|
||||||
<input value={manualForm.region} onChange={(e) => setManualForm(f => ({ ...f, region: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="서울 강남" />
|
<input value={manualForm.region} onChange={(e) => setManualForm(f => ({ ...f, region: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="서울 강남" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-gray-500">음식 종류</label>
|
<label className="text-[10px] text-gray-500">음식 종류</label>
|
||||||
<input value={manualForm.cuisine_type} onChange={(e) => setManualForm(f => ({ ...f, cuisine_type: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="한식, 일식..." />
|
<input value={manualForm.cuisine_type} onChange={(e) => setManualForm(f => ({ ...f, cuisine_type: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="한식, 일식..." />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-gray-500">메뉴</label>
|
<label className="text-[10px] text-gray-500">메뉴</label>
|
||||||
<input value={manualForm.foods_mentioned} onChange={(e) => setManualForm(f => ({ ...f, foods_mentioned: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="메뉴1, 메뉴2" />
|
<input value={manualForm.foods_mentioned} onChange={(e) => setManualForm(f => ({ ...f, foods_mentioned: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="메뉴1, 메뉴2" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-gray-500">게스트</label>
|
<label className="text-[10px] text-gray-500">게스트</label>
|
||||||
<input value={manualForm.guests} onChange={(e) => setManualForm(f => ({ ...f, guests: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="게스트1, 게스트2" />
|
<input value={manualForm.guests} onChange={(e) => setManualForm(f => ({ ...f, guests: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="게스트1, 게스트2" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-gray-500">평가/요약</label>
|
<label className="text-[10px] text-gray-500">평가/요약</label>
|
||||||
<textarea value={manualForm.evaluation} onChange={(e) => setManualForm(f => ({ ...f, evaluation: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" rows={2} placeholder="맛집 평가 내용" />
|
<textarea value={manualForm.evaluation} onChange={(e) => setManualForm(f => ({ ...f, evaluation: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" rows={2} placeholder="맛집 평가 내용" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -1360,7 +1364,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<textarea
|
<textarea
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
className="w-full border rounded p-2 text-xs font-mono mb-2 bg-white text-gray-900"
|
className="w-full border rounded p-2 text-xs font-mono mb-2 bg-surface text-gray-900"
|
||||||
rows={12}
|
rows={12}
|
||||||
placeholder="프롬프트 템플릿 ({title}, {transcript} 변수 사용)"
|
placeholder="프롬프트 템플릿 ({title}, {transcript} 변수 사용)"
|
||||||
/>
|
/>
|
||||||
@@ -1374,39 +1378,39 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500">이름</label>
|
<label className="text-xs text-gray-500">이름</label>
|
||||||
<input value={editRest.name} onChange={(e) => setEditRest({ ...editRest, name: e.target.value })} className="w-full border rounded px-2 py-1 text-sm bg-white text-gray-900" />
|
<input value={editRest.name} onChange={(e) => setEditRest({ ...editRest, name: e.target.value })} className="w-full border rounded px-2 py-1 text-sm bg-surface text-gray-900" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500">종류</label>
|
<label className="text-xs text-gray-500">종류</label>
|
||||||
<input value={editRest.cuisine_type} onChange={(e) => setEditRest({ ...editRest, cuisine_type: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" />
|
<input value={editRest.cuisine_type} onChange={(e) => setEditRest({ ...editRest, cuisine_type: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500">가격대</label>
|
<label className="text-xs text-gray-500">가격대</label>
|
||||||
<input value={editRest.price_range} onChange={(e) => setEditRest({ ...editRest, price_range: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" />
|
<input value={editRest.price_range} onChange={(e) => setEditRest({ ...editRest, price_range: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500">지역</label>
|
<label className="text-xs text-gray-500">지역</label>
|
||||||
<input value={editRest.region} onChange={(e) => setEditRest({ ...editRest, region: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" />
|
<input value={editRest.region} onChange={(e) => setEditRest({ ...editRest, region: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500">주소</label>
|
<label className="text-xs text-gray-500">주소</label>
|
||||||
<input value={editRest.address} onChange={(e) => setEditRest({ ...editRest, address: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" />
|
<input value={editRest.address} onChange={(e) => setEditRest({ ...editRest, address: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500">메뉴 (쉼표 구분)</label>
|
<label className="text-xs text-gray-500">메뉴 (쉼표 구분)</label>
|
||||||
<input value={editRest.foods_mentioned} onChange={(e) => setEditRest({ ...editRest, foods_mentioned: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="메뉴1, 메뉴2, ..." />
|
<input value={editRest.foods_mentioned} onChange={(e) => setEditRest({ ...editRest, foods_mentioned: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="메뉴1, 메뉴2, ..." />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500">평가/요약</label>
|
<label className="text-xs text-gray-500">평가/요약</label>
|
||||||
<textarea value={editRest.evaluation} onChange={(e) => setEditRest({ ...editRest, evaluation: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" rows={2} />
|
<textarea value={editRest.evaluation} onChange={(e) => setEditRest({ ...editRest, evaluation: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" rows={2} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500">게스트 (쉼표 구분)</label>
|
<label className="text-xs text-gray-500">게스트 (쉼표 구분)</label>
|
||||||
<input value={editRest.guests} onChange={(e) => setEditRest({ ...editRest, guests: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" />
|
<input value={editRest.guests} onChange={(e) => setEditRest({ ...editRest, guests: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -1437,7 +1441,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
finally { setSaving(false); }
|
finally { setSaving(false); }
|
||||||
}}
|
}}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
className="px-3 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{saving ? "저장 중..." : "저장"}
|
{saving ? "저장 중..." : "저장"}
|
||||||
</button>
|
</button>
|
||||||
@@ -1451,7 +1455,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={`${isAdmin ? "cursor-pointer hover:bg-gray-50" : ""} -m-3 p-3 rounded group`}
|
className={`${isAdmin ? "cursor-pointer hover:bg-brand-50/50" : ""} -m-3 p-3 rounded group`}
|
||||||
onClick={isAdmin ? () => {
|
onClick={isAdmin ? () => {
|
||||||
let evalText = "";
|
let evalText = "";
|
||||||
if (typeof r.evaluation === "object" && r.evaluation) {
|
if (typeof r.evaluation === "object" && r.evaluation) {
|
||||||
@@ -1545,7 +1549,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<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")}
|
||||||
className="border rounded px-2 py-1 text-xs bg-white text-gray-900"
|
className="border rounded px-2 py-1 text-xs bg-surface text-gray-900"
|
||||||
>
|
>
|
||||||
<option value="auto">자동 (수동→자동생성)</option>
|
<option value="auto">자동 (수동→자동생성)</option>
|
||||||
<option value="manual">수동 자막만</option>
|
<option value="manual">수동 자막만</option>
|
||||||
@@ -1566,7 +1570,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={fetchingTranscript}
|
disabled={fetchingTranscript}
|
||||||
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
className="px-2 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{fetchingTranscript ? "가져오는 중..." : detail.transcript ? "다시 가져오기" : "트랜스크립트 가져오기"}
|
{fetchingTranscript ? "가져오는 중..." : detail.transcript ? "다시 가져오기" : "트랜스크립트 가져오기"}
|
||||||
</button>
|
</button>
|
||||||
@@ -1707,7 +1711,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
value={nameSearch}
|
value={nameSearch}
|
||||||
onChange={(e) => { setNameSearch(e.target.value); setPage(0); }}
|
onChange={(e) => { setNameSearch(e.target.value); setPage(0); }}
|
||||||
onKeyDown={(e) => e.key === "Escape" && setNameSearch("")}
|
onKeyDown={(e) => e.key === "Escape" && setNameSearch("")}
|
||||||
className="border border-r-0 rounded-l px-3 py-2 text-sm w-48 bg-white text-gray-900"
|
className="border border-r-0 rounded-l px-3 py-2 text-sm w-48 bg-surface text-gray-900"
|
||||||
/>
|
/>
|
||||||
{nameSearch ? (
|
{nameSearch ? (
|
||||||
<button
|
<button
|
||||||
@@ -1773,6 +1777,38 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
>
|
>
|
||||||
{bulkTabling ? `테이블링 검색 중 (${bulkTablingProgress.current}/${bulkTablingProgress.total})` : "벌크 테이블링 연결"}
|
{bulkTabling ? `테이블링 검색 중 (${bulkTablingProgress.current}/${bulkTablingProgress.total})` : "벌크 테이블링 연결"}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm("테이블링 매핑을 전부 초기화하시겠습니까?")) return;
|
||||||
|
try {
|
||||||
|
await fetch("/api/restaurants/reset-tabling", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${localStorage.getItem("tasteby_token")}` },
|
||||||
|
});
|
||||||
|
alert("테이블링 매핑 초기화 완료");
|
||||||
|
load();
|
||||||
|
} catch (e) { alert("실패: " + e); }
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 text-xs bg-red-50 text-red-600 border border-red-200 rounded hover:bg-red-100"
|
||||||
|
>
|
||||||
|
테이블링 초기화
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm("캐치테이블 매핑을 전부 초기화하시겠습니까?")) return;
|
||||||
|
try {
|
||||||
|
await fetch("/api/restaurants/reset-catchtable", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${localStorage.getItem("tasteby_token")}` },
|
||||||
|
});
|
||||||
|
alert("캐치테이블 매핑 초기화 완료");
|
||||||
|
load();
|
||||||
|
} catch (e) { alert("실패: " + e); }
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 text-xs bg-red-50 text-red-600 border border-red-200 rounded hover:bg-red-100"
|
||||||
|
>
|
||||||
|
캐치테이블 초기화
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const pending = await fetch(`/api/restaurants/catchtable-pending`, {
|
const pending = await fetch(`/api/restaurants/catchtable-pending`, {
|
||||||
@@ -1847,9 +1883,9 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow overflow-auto">
|
<div className="bg-surface rounded-lg shadow overflow-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold">
|
<thead className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm font-semibold">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("name")}>이름{sortIcon("name")}</th>
|
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("name")}>이름{sortIcon("name")}</th>
|
||||||
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("region")}>지역{sortIcon("region")}</th>
|
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("region")}>지역{sortIcon("region")}</th>
|
||||||
@@ -1864,7 +1900,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<tr
|
<tr
|
||||||
key={r.id}
|
key={r.id}
|
||||||
onClick={() => handleSelect(r)}
|
onClick={() => handleSelect(r)}
|
||||||
className={`border-b cursor-pointer hover:bg-gray-50 ${selected?.id === r.id ? "bg-blue-50" : ""}`}
|
className={`border-b cursor-pointer hover:bg-brand-50/50 ${selected?.id === r.id ? "bg-brand-50" : ""}`}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 font-medium">{r.name}</td>
|
<td className="px-4 py-3 font-medium">{r.name}</td>
|
||||||
<td className="px-4 py-3 text-gray-600 text-xs">{r.region || "-"}</td>
|
<td className="px-4 py-3 text-gray-600 text-xs">{r.region || "-"}</td>
|
||||||
@@ -1909,7 +1945,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
|
|
||||||
{/* 식당 상세/수정 패널 */}
|
{/* 식당 상세/수정 패널 */}
|
||||||
{selected && (
|
{selected && (
|
||||||
<div className="mt-6 bg-white rounded-lg shadow p-4">
|
<div className="mt-6 bg-surface rounded-lg shadow p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="font-semibold text-base">{selected.name}</h3>
|
<h3 className="font-semibold text-base">{selected.name}</h3>
|
||||||
<button onClick={() => setSelected(null)} className="text-gray-400 hover:text-gray-600 text-xl leading-none">x</button>
|
<button onClick={() => setSelected(null)} className="text-gray-400 hover:text-gray-600 text-xl leading-none">x</button>
|
||||||
@@ -1931,7 +1967,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<input
|
<input
|
||||||
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 bg-white text-gray-900"
|
className="w-full border rounded px-2 py-1.5 text-sm bg-surface text-gray-900"
|
||||||
disabled={!isAdmin}
|
disabled={!isAdmin}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1949,7 +1985,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
href={`https://www.google.com/maps/place/?q=place_id:${selected.google_place_id}`}
|
href={`https://www.google.com/maps/place/?q=place_id:${selected.google_place_id}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline text-xs"
|
className="text-brand-600 hover:underline text-xs"
|
||||||
>
|
>
|
||||||
Google Maps에서 보기
|
Google Maps에서 보기
|
||||||
</a>
|
</a>
|
||||||
@@ -1964,7 +2000,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<span className="text-xs text-gray-400">검색완료-없음</span>
|
<span className="text-xs text-gray-400">검색완료-없음</span>
|
||||||
) : selected.tabling_url ? (
|
) : selected.tabling_url ? (
|
||||||
<a href={selected.tabling_url} target="_blank" rel="noopener noreferrer"
|
<a href={selected.tabling_url} target="_blank" rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline text-xs">{selected.tabling_url}</a>
|
className="text-brand-600 hover:underline text-xs">{selected.tabling_url}</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-gray-400">미연결</span>
|
<span className="text-xs text-gray-400">미연결</span>
|
||||||
)}
|
)}
|
||||||
@@ -2015,7 +2051,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<span className="text-xs text-gray-400">검색완료-없음</span>
|
<span className="text-xs text-gray-400">검색완료-없음</span>
|
||||||
) : selected.catchtable_url ? (
|
) : selected.catchtable_url ? (
|
||||||
<a href={selected.catchtable_url} target="_blank" rel="noopener noreferrer"
|
<a href={selected.catchtable_url} target="_blank" rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline text-xs">{selected.catchtable_url}</a>
|
className="text-brand-600 hover:underline text-xs">{selected.catchtable_url}</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-gray-400">미연결</span>
|
<span className="text-xs text-gray-400">미연결</span>
|
||||||
)}
|
)}
|
||||||
@@ -2067,7 +2103,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<span className="px-1.5 py-0.5 bg-red-50 text-red-600 rounded text-[10px] font-medium shrink-0">
|
<span className="px-1.5 py-0.5 bg-red-50 text-red-600 rounded text-[10px] font-medium shrink-0">
|
||||||
{v.channel_name}
|
{v.channel_name}
|
||||||
</span>
|
</span>
|
||||||
<a href={v.url} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline truncate">
|
<a href={v.url} target="_blank" rel="noopener noreferrer" className="text-brand-600 hover:underline truncate">
|
||||||
{v.title}
|
{v.title}
|
||||||
</a>
|
</a>
|
||||||
<span className="text-gray-400 shrink-0">{v.published_at?.slice(0, 10)}</span>
|
<span className="text-gray-400 shrink-0">{v.published_at?.slice(0, 10)}</span>
|
||||||
@@ -2081,7 +2117,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
{isAdmin && <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-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{saving ? "저장 중..." : "저장"}
|
{saving ? "저장 중..." : "저장"}
|
||||||
</button>}
|
</button>}
|
||||||
@@ -2192,9 +2228,9 @@ function UsersPanel() {
|
|||||||
<h2 className="text-lg font-bold">유저 관리 ({total}명)</h2>
|
<h2 className="text-lg font-bold">유저 관리 ({total}명)</h2>
|
||||||
|
|
||||||
{/* Users Table */}
|
{/* Users Table */}
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
<div className="bg-surface rounded-lg shadow overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold">
|
<thead className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm font-semibold">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-4 py-2">사용자</th>
|
<th className="text-left px-4 py-2">사용자</th>
|
||||||
<th className="text-left px-4 py-2">이메일</th>
|
<th className="text-left px-4 py-2">이메일</th>
|
||||||
@@ -2210,8 +2246,8 @@ function UsersPanel() {
|
|||||||
onClick={() => handleSelectUser(u)}
|
onClick={() => handleSelectUser(u)}
|
||||||
className={`border-t cursor-pointer transition-colors ${
|
className={`border-t cursor-pointer transition-colors ${
|
||||||
selectedUser?.id === u.id
|
selectedUser?.id === u.id
|
||||||
? "bg-blue-50"
|
? "bg-brand-50"
|
||||||
: "hover:bg-gray-50"
|
: "hover:bg-brand-50/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
@@ -2244,7 +2280,7 @@ function UsersPanel() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-center">
|
<td className="px-4 py-2 text-center">
|
||||||
{u.review_count > 0 ? (
|
{u.review_count > 0 ? (
|
||||||
<span className="inline-block px-2 py-0.5 bg-blue-50 text-blue-600 rounded-full text-xs font-medium">
|
<span className="inline-block px-2 py-0.5 bg-brand-50 text-brand-600 rounded-full text-xs font-medium">
|
||||||
{u.review_count}
|
{u.review_count}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -2285,7 +2321,7 @@ function UsersPanel() {
|
|||||||
|
|
||||||
{/* Selected User Detail */}
|
{/* Selected User Detail */}
|
||||||
{selectedUser && (
|
{selectedUser && (
|
||||||
<div className="bg-white rounded-lg shadow p-5 space-y-4">
|
<div className="bg-surface rounded-lg shadow p-5 space-y-4">
|
||||||
<div className="flex items-center gap-3 pb-3 border-b">
|
<div className="flex items-center gap-3 pb-3 border-b">
|
||||||
{selectedUser.avatar_url ? (
|
{selectedUser.avatar_url ? (
|
||||||
<img
|
<img
|
||||||
@@ -2352,7 +2388,7 @@ function UsersPanel() {
|
|||||||
|
|
||||||
{/* Reviews */}
|
{/* Reviews */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-sm mb-2 text-blue-600">
|
<h3 className="font-semibold text-sm mb-2 text-brand-600">
|
||||||
작성한 리뷰 ({reviews.length})
|
작성한 리뷰 ({reviews.length})
|
||||||
</h3>
|
</h3>
|
||||||
{reviews.length === 0 ? (
|
{reviews.length === 0 ? (
|
||||||
@@ -2476,7 +2512,7 @@ function DaemonPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Schedule Config */}
|
{/* Schedule Config */}
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<div className="bg-surface rounded-lg shadow p-6">
|
||||||
<h2 className="text-lg font-semibold mb-4">스케줄 설정</h2>
|
<h2 className="text-lg font-semibold mb-4">스케줄 설정</h2>
|
||||||
<p className="text-xs text-gray-500 mb-4">
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
데몬이 실행 중일 때, 아래 설정에 따라 자동으로 채널 스캔 및 영상 처리를 수행합니다.
|
데몬이 실행 중일 때, 아래 설정에 따라 자동으로 채널 스캔 및 영상 처리를 수행합니다.
|
||||||
@@ -2569,7 +2605,7 @@ function DaemonPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 disabled:opacity-50"
|
className="px-4 py-2 bg-brand-600 text-white text-sm rounded hover:bg-brand-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{saving ? "저장 중..." : "설정 저장"}
|
{saving ? "저장 중..." : "설정 저장"}
|
||||||
</button>
|
</button>
|
||||||
@@ -2578,7 +2614,7 @@ function DaemonPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Manual Triggers */}
|
{/* Manual Triggers */}
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<div className="bg-surface rounded-lg shadow p-6">
|
||||||
<h2 className="text-lg font-semibold mb-4">수동 실행</h2>
|
<h2 className="text-lg font-semibold mb-4">수동 실행</h2>
|
||||||
<p className="text-xs text-gray-500 mb-4">
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
스케줄과 관계없이 즉시 실행합니다. 처리 시간이 걸릴 수 있습니다.
|
스케줄과 관계없이 즉시 실행합니다. 처리 시간이 걸릴 수 있습니다.
|
||||||
|
|||||||
@@ -86,6 +86,17 @@ html, body, #__next {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Material Symbols */
|
||||||
|
.material-symbols-rounded {
|
||||||
|
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: 1;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.material-symbols-rounded.filled {
|
||||||
|
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||||
|
}
|
||||||
|
|
||||||
/* Safe area for iOS bottom nav */
|
/* Safe area for iOS bottom nav */
|
||||||
.safe-area-bottom {
|
.safe-area-bottom {
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
|||||||
@@ -28,7 +28,13 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="ko" className="bg-background" suppressHydrationWarning>
|
<html lang="ko" className="bg-background" style={{ colorScheme: "light" }} suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<meta name="color-scheme" content="light only" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
<body className={`${pretendard.variable} ${geist.variable} font-sans antialiased`}>
|
<body className={`${pretendard.variable} ${geist.variable} font-sans antialiased`}>
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import RestaurantDetail from "@/components/RestaurantDetail";
|
|||||||
import MyReviewsList from "@/components/MyReviewsList";
|
import MyReviewsList from "@/components/MyReviewsList";
|
||||||
import BottomSheet from "@/components/BottomSheet";
|
import BottomSheet from "@/components/BottomSheet";
|
||||||
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||||
|
import Icon from "@/components/Icon";
|
||||||
|
|
||||||
function useDragScroll() {
|
function useDragScroll() {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -587,8 +588,11 @@ export default function Home() {
|
|||||||
{/* ── Header row 1: Logo + User ── */}
|
{/* ── Header row 1: Logo + User ── */}
|
||||||
<header className="bg-surface/80 backdrop-blur-md border-b dark:border-gray-800 shrink-0">
|
<header className="bg-surface/80 backdrop-blur-md border-b dark:border-gray-800 shrink-0">
|
||||||
<div className="px-5 py-3 flex items-center justify-between">
|
<div className="px-5 py-3 flex items-center justify-between">
|
||||||
<button onClick={handleReset} className="text-lg font-bold whitespace-nowrap">
|
<button onClick={handleReset} className="shrink-0">
|
||||||
Tasteby
|
<picture>
|
||||||
|
<source srcSet="/logo-dark-80h.png" media="(prefers-color-scheme: dark)" />
|
||||||
|
<img src="/logo-80h.png" alt="Tasteby" className="h-7 md:h-8" />
|
||||||
|
</picture>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Desktop: search + filters */}
|
{/* Desktop: search + filters */}
|
||||||
@@ -603,7 +607,7 @@ export default function Home() {
|
|||||||
className="p-1.5 text-gray-500 dark:text-gray-500 hover:text-brand-500 dark:hover:text-brand-400 transition-colors"
|
className="p-1.5 text-gray-500 dark:text-gray-500 hover:text-brand-500 dark:hover:text-brand-400 transition-colors"
|
||||||
title="초기화"
|
title="초기화"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" className="w-4.5 h-4.5 fill-current"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
<Icon name="refresh" size={18} />
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-500 tabular-nums">
|
<span className="text-xs text-gray-500 dark:text-gray-500 tabular-nums">
|
||||||
{filteredRestaurants.length}개
|
{filteredRestaurants.length}개
|
||||||
@@ -629,7 +633,7 @@ export default function Home() {
|
|||||||
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-rose-300 hover:text-rose-500"
|
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-rose-300 hover:text-rose-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{showFavorites ? "♥ 내 찜" : "♡ 찜"}
|
<Icon name="favorite" size={14} filled={showFavorites} className="mr-0.5" />{showFavorites ? "내 찜" : "찜"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleToggleMyReviews}
|
onClick={handleToggleMyReviews}
|
||||||
@@ -689,7 +693,7 @@ export default function Home() {
|
|||||||
}`}
|
}`}
|
||||||
style={{ width: "200px" }}
|
style={{ width: "200px" }}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 shrink-0 text-red-500" viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
|
<Icon name="play_circle" size={16} filled className="shrink-0 text-red-500" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className={`text-xs font-semibold truncate ${
|
<p className={`text-xs font-semibold truncate ${
|
||||||
channelFilter === ch.channel_name ? "text-brand-600 dark:text-brand-400" : "dark:text-gray-200"
|
channelFilter === ch.channel_name ? "text-brand-600 dark:text-brand-400" : "dark:text-gray-200"
|
||||||
@@ -744,7 +748,7 @@ export default function Home() {
|
|||||||
className="text-gray-400 hover:text-brand-500 transition-colors"
|
className="text-gray-400 hover:text-brand-500 transition-colors"
|
||||||
title="음식 필터 초기화"
|
title="음식 필터 초기화"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" className="w-3 h-3 fill-current"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
<Icon name="close" size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -803,7 +807,7 @@ export default function Home() {
|
|||||||
className="text-gray-400 hover:text-brand-500 transition-colors"
|
className="text-gray-400 hover:text-brand-500 transition-colors"
|
||||||
title="지역 필터 초기화"
|
title="지역 필터 초기화"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" className="w-3 h-3 fill-current"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
<Icon name="close" size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -821,7 +825,7 @@ export default function Home() {
|
|||||||
}}
|
}}
|
||||||
className="flex items-center gap-1 rounded-lg px-2 py-1 bg-gray-50 dark:bg-gray-800/50 text-gray-500 dark:text-gray-400 hover:text-brand-500 transition-colors"
|
className="flex items-center gap-1 rounded-lg px-2 py-1 bg-gray-50 dark:bg-gray-800/50 text-gray-500 dark:text-gray-400 hover:text-brand-500 transition-colors"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" className="w-3 h-3 fill-current"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
<Icon name="close" size={12} />
|
||||||
<span>전체보기</span>
|
<span>전체보기</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -855,7 +859,7 @@ export default function Home() {
|
|||||||
}`}
|
}`}
|
||||||
title="내 위치 주변 식당만 표시"
|
title="내 위치 주변 식당만 표시"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 fill-current"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 010-5 2.5 2.5 0 010 5z"/></svg>
|
<Icon name="location_on" size={14} />
|
||||||
<span>{boundsFilterOn ? "내위치 ON" : "내위치"}</span>
|
<span>{boundsFilterOn ? "내위치 ON" : "내위치"}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -873,7 +877,7 @@ export default function Home() {
|
|||||||
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400"
|
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{showFavorites ? "♥ 찜" : "♡ 찜"}
|
<Icon name="favorite" size={14} filled={showFavorites} className="mr-0.5" />{showFavorites ? "찜" : "찜"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleToggleMyReviews}
|
onClick={handleToggleMyReviews}
|
||||||
@@ -926,7 +930,7 @@ export default function Home() {
|
|||||||
style={{ minWidth: "140px", maxWidth: "170px" }}
|
style={{ minWidth: "140px", maxWidth: "170px" }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<svg className="w-3.5 h-3.5 shrink-0 text-red-500" viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
|
<Icon name="play_circle" size={14} filled className="shrink-0 text-red-500" />
|
||||||
<p className={`text-xs font-semibold truncate ${
|
<p className={`text-xs font-semibold truncate ${
|
||||||
channelFilter === ch.channel_name ? "text-brand-600 dark:text-brand-400" : "dark:text-gray-200"
|
channelFilter === ch.channel_name ? "text-brand-600 dark:text-brand-400" : "dark:text-gray-200"
|
||||||
}`}>{ch.channel_name}</p>
|
}`}>{ch.channel_name}</p>
|
||||||
@@ -981,7 +985,7 @@ export default function Home() {
|
|||||||
</select>
|
</select>
|
||||||
{(cuisineFilter || priceFilter) && (
|
{(cuisineFilter || priceFilter) && (
|
||||||
<button onClick={() => { setCuisineFilter(""); setPriceFilter(""); }} className="text-gray-400 hover:text-brand-500">
|
<button onClick={() => { setCuisineFilter(""); setPriceFilter(""); }} className="text-gray-400 hover:text-brand-500">
|
||||||
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 fill-current"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
<Icon name="close" size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] text-gray-400 ml-auto tabular-nums">{filteredRestaurants.length}개</span>
|
<span className="text-[10px] text-gray-400 ml-auto tabular-nums">{filteredRestaurants.length}개</span>
|
||||||
@@ -1030,7 +1034,7 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
{countryFilter && (
|
{countryFilter && (
|
||||||
<button onClick={() => { setCountryFilter(""); setCityFilter(""); setDistrictFilter(""); setRegionFlyTo(null); }} className="text-gray-400 hover:text-brand-500">
|
<button onClick={() => { setCountryFilter(""); setCityFilter(""); setDistrictFilter(""); setRegionFlyTo(null); }} className="text-gray-400 hover:text-brand-500">
|
||||||
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 fill-current"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
<Icon name="close" size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -1060,7 +1064,7 @@ export default function Home() {
|
|||||||
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400"
|
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" className="w-3 h-3 fill-current"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 010-5 2.5 2.5 0 010 5z"/></svg>
|
<Icon name="location_on" size={12} />
|
||||||
<span>{boundsFilterOn ? "내위치 ON" : "내위치"}</span>
|
<span>{boundsFilterOn ? "내위치 ON" : "내위치"}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1238,22 +1242,12 @@ export default function Home() {
|
|||||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-40 border-t dark:border-gray-800 bg-surface safe-area-bottom">
|
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-40 border-t dark:border-gray-800 bg-surface safe-area-bottom">
|
||||||
<div className="flex items-stretch h-14">
|
<div className="flex items-stretch h-14">
|
||||||
{([
|
{([
|
||||||
{ key: "home", label: "홈", icon: (
|
{ key: "home", label: "홈", iconName: "home" },
|
||||||
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M12 3l9 8h-3v9h-5v-6h-2v6H6v-9H3z"/></svg>
|
{ key: "list", label: "식당 목록", iconName: "restaurant_menu" },
|
||||||
)},
|
{ key: "nearby", label: "내주변", iconName: "near_me" },
|
||||||
{ key: "list", label: "식당 목록", icon: (
|
{ key: "favorites", label: "찜", iconName: "favorite" },
|
||||||
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M3 4h18v2H3zm0 7h18v2H3zm0 7h18v2H3z"/></svg>
|
{ key: "profile", label: "내정보", iconName: "person" },
|
||||||
)},
|
] as { key: "home" | "list" | "nearby" | "favorites" | "profile"; label: string; iconName: string }[]).map((tab) => (
|
||||||
{ key: "nearby", label: "내주변", icon: (
|
|
||||||
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>
|
|
||||||
)},
|
|
||||||
{ key: "favorites", label: "찜", icon: (
|
|
||||||
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
|
|
||||||
)},
|
|
||||||
{ key: "profile", label: "내정보", icon: (
|
|
||||||
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
|
|
||||||
)},
|
|
||||||
] as { key: "home" | "list" | "nearby" | "favorites" | "profile"; label: string; icon: React.ReactNode }[]).map((tab) => (
|
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => handleMobileTab(tab.key)}
|
onClick={() => handleMobileTab(tab.key)}
|
||||||
@@ -1263,7 +1257,7 @@ export default function Home() {
|
|||||||
: "text-gray-500 dark:text-gray-500"
|
: "text-gray-500 dark:text-gray-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.icon}
|
<Icon name={tab.iconName} size={22} filled={mobileTab === tab.key} />
|
||||||
<span className="text-[10px] font-medium">{tab.label}</span>
|
<span className="text-[10px] font-medium">{tab.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
23
frontend/src/components/Icon.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
interface IconProps {
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
filled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material Symbols Rounded icon wrapper.
|
||||||
|
* Usage: <Icon name="search" size={20} />
|
||||||
|
*/
|
||||||
|
export default function Icon({ name, size = 20, filled, className = "" }: IconProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`material-symbols-rounded ${filled ? "filled" : ""} ${className}`}
|
||||||
|
style={{ fontSize: size }}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} 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";
|
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||||
|
import Icon from "@/components/Icon";
|
||||||
|
|
||||||
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 || "";
|
||||||
@@ -124,7 +125,7 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
|
|||||||
textDecoration: isClosed ? "line-through" : "none",
|
textDecoration: isClosed ? "line-through" : "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ marginRight: 3 }}>{getCuisineIcon(r.cuisine_type)}</span>
|
<span className="material-symbols-rounded" style={{ fontSize: 14, marginRight: 3, verticalAlign: "middle", color: "#E8720C" }}>{getCuisineIcon(r.cuisine_type)}</span>
|
||||||
{r.name}
|
{r.name}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -149,7 +150,7 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
|
|||||||
>
|
>
|
||||||
<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" }}>{getCuisineIcon(infoTarget.cuisine_type)} {infoTarget.name}</h3>
|
<h3 className="font-bold text-base" style={{ color: "#171717" }}><span className="material-symbols-rounded" style={{ fontSize: 18, verticalAlign: "middle", color: "#E8720C", marginRight: 4 }}>{getCuisineIcon(infoTarget.cuisine_type)}</span>{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>
|
||||||
)}
|
)}
|
||||||
@@ -234,9 +235,7 @@ export default function MapView({ restaurants, selected, onSelectRestaurant, onB
|
|||||||
className="absolute top-2 right-2 w-9 h-9 bg-surface rounded-lg shadow-md flex items-center justify-center text-gray-600 dark:text-gray-300 hover:text-brand-500 dark:hover:text-brand-400 transition-colors z-10"
|
className="absolute top-2 right-2 w-9 h-9 bg-surface rounded-lg shadow-md flex items-center justify-center text-gray-600 dark:text-gray-300 hover:text-brand-500 dark:hover:text-brand-400 transition-colors z-10"
|
||||||
title="내 위치"
|
title="내 위치"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current">
|
<Icon name="my_location" size={20} />
|
||||||
<path d="M12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm8.94 3A8.994 8.994 0 0013 3.06V1h-2v2.06A8.994 8.994 0 003.06 11H1v2h2.06A8.994 8.994 0 0011 20.94V23h2v-2.06A8.994 8.994 0 0020.94 13H23v-2h-2.06zM12 19c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{channelNames.length > 0 && (
|
{channelNames.length > 0 && (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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";
|
import { RestaurantDetailSkeleton } from "@/components/Skeleton";
|
||||||
|
import Icon from "@/components/Icon";
|
||||||
|
|
||||||
interface RestaurantDetailProps {
|
interface RestaurantDetailProps {
|
||||||
restaurant: Restaurant;
|
restaurant: Restaurant;
|
||||||
@@ -60,7 +61,7 @@ export default function RestaurantDetail({
|
|||||||
}`}
|
}`}
|
||||||
title={favorited ? "찜 해제" : "찜하기"}
|
title={favorited ? "찜 해제" : "찜하기"}
|
||||||
>
|
>
|
||||||
{favorited ? "♥" : "♡"}
|
<Icon name="favorite" size={20} filled={favorited} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{restaurant.business_status === "CLOSED_PERMANENTLY" && (
|
{restaurant.business_status === "CLOSED_PERMANENTLY" && (
|
||||||
@@ -78,7 +79,7 @@ export default function RestaurantDetail({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none"
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none"
|
||||||
>
|
>
|
||||||
x
|
<Icon name="close" size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -197,7 +198,7 @@ export default function RestaurantDetail({
|
|||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
{v.channel_name && (
|
{v.channel_name && (
|
||||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400 rounded text-[10px] font-semibold">
|
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400 rounded text-[10px] font-semibold">
|
||||||
<span className="text-[9px]">▶</span>
|
<Icon name="play_circle" size={11} filled className="text-red-400" />
|
||||||
{v.channel_name}
|
{v.channel_name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -213,9 +214,7 @@ export default function RestaurantDetail({
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-red-600 dark:text-red-400 hover:underline"
|
className="inline-flex items-center gap-1.5 text-sm font-medium text-red-600 dark:text-red-400 hover:underline"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" className="w-4 h-4 flex-shrink-0 fill-current" aria-hidden="true">
|
<Icon name="play_circle" size={16} filled className="flex-shrink-0" />
|
||||||
<path d="M23.5 6.2c-.3-1-1-1.8-2-2.1C19.6 3.5 12 3.5 12 3.5s-7.6 0-9.5.6c-1 .3-1.7 1.1-2 2.1C0 8.1 0 12 0 12s0 3.9.5 5.8c.3 1 1 1.8 2 2.1 1.9.6 9.5.6 9.5.6s7.6 0 9.5-.6c1-.3 1.7-1.1 2-2.1.5-1.9.5-5.8.5-5.8s0-3.9-.5-5.8zM9.5 15.5V8.5l6.3 3.5-6.3 3.5z"/>
|
|
||||||
</svg>
|
|
||||||
{v.title}
|
{v.title}
|
||||||
</a>
|
</a>
|
||||||
{v.foods_mentioned.length > 0 && (
|
{v.foods_mentioned.length > 0 && (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { Restaurant } from "@/lib/api";
|
import type { Restaurant } from "@/lib/api";
|
||||||
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||||
|
import Icon from "@/components/Icon";
|
||||||
import { RestaurantListSkeleton } from "@/components/Skeleton";
|
import { RestaurantListSkeleton } from "@/components/Skeleton";
|
||||||
|
|
||||||
interface RestaurantListProps {
|
interface RestaurantListProps {
|
||||||
@@ -45,7 +46,7 @@ export default function RestaurantList({
|
|||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<h4 className="font-semibold text-sm text-gray-900 dark:text-gray-100">
|
<h4 className="font-semibold text-sm text-gray-900 dark:text-gray-100">
|
||||||
<span className="mr-1">{getCuisineIcon(r.cuisine_type)}</span>
|
<Icon name={getCuisineIcon(r.cuisine_type)} size={16} className="mr-0.5 text-brand-600" />
|
||||||
{r.name}
|
{r.name}
|
||||||
</h4>
|
</h4>
|
||||||
{r.rating && (
|
{r.rating && (
|
||||||
@@ -87,7 +88,7 @@ export default function RestaurantList({
|
|||||||
key={ch}
|
key={ch}
|
||||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 rounded-full text-[10px] font-medium"
|
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 rounded-full text-[10px] font-medium"
|
||||||
>
|
>
|
||||||
<svg className="w-2.5 h-2.5 shrink-0 text-red-400" viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
|
<Icon name="play_circle" size={11} filled className="shrink-0 text-red-400" />
|
||||||
{ch}
|
{ch}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import Icon from "@/components/Icon";
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
onSearch: (query: string, mode: "keyword" | "semantic" | "hybrid") => void;
|
onSearch: (query: string, mode: "keyword" | "semantic" | "hybrid") => void;
|
||||||
@@ -19,18 +20,9 @@ export default function SearchBar({ onSearch, isLoading }: SearchBarProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="relative">
|
<form onSubmit={handleSubmit} className="relative">
|
||||||
<svg
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
|
||||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500 pointer-events-none"
|
<Icon name="search" size={16} />
|
||||||
viewBox="0 0 24 24"
|
</span>
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
||||||
</svg>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
|
|||||||
@@ -1,39 +1,39 @@
|
|||||||
/**
|
/**
|
||||||
* Cuisine type → icon mapping.
|
* Cuisine type → Material Symbols icon name mapping.
|
||||||
* Works with "대분류|소분류" format (e.g. "한식|국밥/해장국").
|
* Works with "대분류|소분류" format (e.g. "한식|국밥/해장국").
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CUISINE_ICON_MAP: Record<string, string> = {
|
const CUISINE_ICON_MAP: Record<string, string> = {
|
||||||
"한식": "🍚",
|
"한식": "rice_bowl",
|
||||||
"일식": "🍣",
|
"일식": "set_meal",
|
||||||
"중식": "🥟",
|
"중식": "ramen_dining",
|
||||||
"양식": "🍝",
|
"양식": "dinner_dining",
|
||||||
"아시아": "🍜",
|
"아시아": "ramen_dining",
|
||||||
"기타": "🍴",
|
"기타": "flatware",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sub-category overrides for more specific icons
|
// Sub-category overrides for more specific icons
|
||||||
const SUB_ICON_RULES: { keyword: string; icon: string }[] = [
|
const SUB_ICON_RULES: { keyword: string; icon: string }[] = [
|
||||||
{ keyword: "회/횟집", icon: "🐟" },
|
{ keyword: "회/횟집", icon: "set_meal" },
|
||||||
{ keyword: "해산물", icon: "🦐" },
|
{ keyword: "해산물", icon: "set_meal" },
|
||||||
{ keyword: "삼겹살/돼지구이", icon: "🥩" },
|
{ keyword: "삼겹살/돼지구이", icon: "kebab_dining" },
|
||||||
{ keyword: "소고기/한우구이", icon: "🥩" },
|
{ keyword: "소고기/한우구이", icon: "kebab_dining" },
|
||||||
{ keyword: "곱창/막창", icon: "🥩" },
|
{ keyword: "곱창/막창", icon: "kebab_dining" },
|
||||||
{ keyword: "닭/오리구이", icon: "🍗" },
|
{ keyword: "닭/오리구이", icon: "takeout_dining" },
|
||||||
{ keyword: "스테이크", icon: "🥩" },
|
{ keyword: "스테이크", icon: "kebab_dining" },
|
||||||
{ keyword: "햄버거", icon: "🍔" },
|
{ keyword: "햄버거", icon: "lunch_dining" },
|
||||||
{ keyword: "피자", icon: "🍕" },
|
{ keyword: "피자", icon: "local_pizza" },
|
||||||
{ keyword: "카페/디저트", icon: "☕" },
|
{ keyword: "카페/디저트", icon: "coffee" },
|
||||||
{ keyword: "베이커리", icon: "🥐" },
|
{ keyword: "베이커리", icon: "bakery_dining" },
|
||||||
{ keyword: "치킨", icon: "🍗" },
|
{ keyword: "치킨", icon: "takeout_dining" },
|
||||||
{ keyword: "주점/포차", icon: "🍺" },
|
{ keyword: "주점/포차", icon: "local_bar" },
|
||||||
{ keyword: "이자카야", icon: "🍶" },
|
{ keyword: "이자카야", icon: "sake" },
|
||||||
{ keyword: "라멘", icon: "🍜" },
|
{ keyword: "라멘", icon: "ramen_dining" },
|
||||||
{ keyword: "국밥/해장국", icon: "🍲" },
|
{ keyword: "국밥/해장국", icon: "soup_kitchen" },
|
||||||
{ keyword: "분식", icon: "🍜" },
|
{ keyword: "분식", icon: "ramen_dining" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_ICON = "🍴";
|
const DEFAULT_ICON = "flatware";
|
||||||
|
|
||||||
export function getCuisineIcon(cuisineType: string | null | undefined): string {
|
export function getCuisineIcon(cuisineType: string | null | undefined): string {
|
||||||
if (!cuisineType) return DEFAULT_ICON;
|
if (!cuisineType) return DEFAULT_ICON;
|
||||||
|
|||||||