Compare commits
6 Commits
2a0ee1d2cc
...
88c1b4243e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88c1b4243e | ||
|
|
824c171158 | ||
|
|
4f8b4f435e | ||
|
|
50018c17fa | ||
|
|
ec8330a978 | ||
|
|
e85e135c8b |
@@ -77,9 +77,10 @@ public class ChannelController {
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, String> body) {
|
||||
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
channelService.update(id, body.get("description"), body.get("tags"));
|
||||
Integer sortOrder = body.get("sort_order") != null ? ((Number) body.get("sort_order")).intValue() : null;
|
||||
channelService.update(id, (String) body.get("description"), (String) body.get("tags"), sortOrder);
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@@ -198,10 +198,18 @@ public class RestaurantController {
|
||||
if (!results.isEmpty()) {
|
||||
String url = String.valueOf(results.get(0).get("url"));
|
||||
String title = String.valueOf(results.get(0).get("title"));
|
||||
restaurantService.update(r.getId(), Map.of("tabling_url", url));
|
||||
linked++;
|
||||
emit(emitter, Map.of("type", "done", "current", i + 1,
|
||||
"name", r.getName(), "url", url, "title", title));
|
||||
if (isNameSimilar(r.getName(), title)) {
|
||||
restaurantService.update(r.getId(), Map.of("tabling_url", url));
|
||||
linked++;
|
||||
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 {
|
||||
restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
|
||||
notFound++;
|
||||
@@ -246,6 +254,23 @@ public class RestaurantController {
|
||||
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 검색 */
|
||||
@GetMapping("/{id}/catchtable-search")
|
||||
public List<Map<String, Object>> catchtableSearch(@PathVariable String id) {
|
||||
@@ -311,10 +336,18 @@ public class RestaurantController {
|
||||
if (!results.isEmpty()) {
|
||||
String url = String.valueOf(results.get(0).get("url"));
|
||||
String title = String.valueOf(results.get(0).get("title"));
|
||||
restaurantService.update(r.getId(), Map.of("catchtable_url", url));
|
||||
linked++;
|
||||
emit(emitter, Map.of("type", "done", "current", i + 1,
|
||||
"name", r.getName(), "url", url, "title", title));
|
||||
if (isNameSimilar(r.getName(), title)) {
|
||||
restaurantService.update(r.getId(), Map.of("catchtable_url", url));
|
||||
linked++;
|
||||
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 {
|
||||
restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
|
||||
notFound++;
|
||||
@@ -489,6 +522,31 @@ public class RestaurantController {
|
||||
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) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().data(objectMapper.writeValueAsString(data)));
|
||||
|
||||
@@ -16,6 +16,7 @@ public class Channel {
|
||||
private String titleFilter;
|
||||
private String description;
|
||||
private String tags;
|
||||
private Integer sortOrder;
|
||||
private int videoCount;
|
||||
private String lastVideoAt;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ public interface ChannelMapper {
|
||||
|
||||
Channel findByChannelId(@Param("channelId") String channelId);
|
||||
|
||||
void updateDescriptionTags(@Param("id") String id,
|
||||
@Param("description") String description,
|
||||
@Param("tags") String tags);
|
||||
void updateChannel(@Param("id") String id,
|
||||
@Param("description") String description,
|
||||
@Param("tags") String tags,
|
||||
@Param("sortOrder") Integer sortOrder);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ public interface RestaurantMapper {
|
||||
|
||||
List<Restaurant> findWithoutCatchtable();
|
||||
|
||||
void resetTablingUrls();
|
||||
|
||||
void resetCatchtableUrls();
|
||||
|
||||
List<Map<String, Object>> findForRemapCuisine();
|
||||
|
||||
List<Map<String, Object>> findForRemapFoods();
|
||||
|
||||
@@ -39,7 +39,7 @@ public class ChannelService {
|
||||
return mapper.findByChannelId(channelId);
|
||||
}
|
||||
|
||||
public void update(String id, String description, String tags) {
|
||||
mapper.updateDescriptionTags(id, description, tags);
|
||||
public void update(String id, String description, String tags, Integer sortOrder) {
|
||||
mapper.updateChannel(id, description, tags, sortOrder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,16 @@ public class RestaurantService {
|
||||
return mapper.findWithoutCatchtable();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void resetTablingUrls() {
|
||||
mapper.resetTablingUrls();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void resetCatchtableUrls() {
|
||||
mapper.resetCatchtableUrls();
|
||||
}
|
||||
|
||||
public Restaurant findById(String id) {
|
||||
Restaurant restaurant = mapper.findById(id);
|
||||
if (restaurant == null) return null;
|
||||
|
||||
@@ -9,17 +9,18 @@
|
||||
<result property="titleFilter" column="title_filter"/>
|
||||
<result property="description" column="description"/>
|
||||
<result property="tags" column="tags"/>
|
||||
<result property="sortOrder" column="sort_order"/>
|
||||
<result property="videoCount" column="video_count"/>
|
||||
<result property="lastVideoAt" column="last_video_at"/>
|
||||
</resultMap>
|
||||
|
||||
<select id="findAllActive" resultMap="channelResultMap">
|
||||
SELECT c.id, c.channel_id, c.channel_name, c.title_filter, c.description, c.tags,
|
||||
SELECT c.id, c.channel_id, c.channel_name, c.title_filter, c.description, c.tags, c.sort_order,
|
||||
(SELECT COUNT(*) FROM videos v WHERE v.channel_id = c.id) AS video_count,
|
||||
(SELECT MAX(v.published_at) FROM videos v WHERE v.channel_id = c.id) AS last_video_at
|
||||
FROM channels c
|
||||
WHERE c.is_active = 1
|
||||
ORDER BY c.channel_name
|
||||
ORDER BY c.sort_order, c.channel_name
|
||||
</select>
|
||||
|
||||
<insert id="insert">
|
||||
@@ -37,8 +38,8 @@
|
||||
WHERE id = #{id} AND is_active = 1
|
||||
</update>
|
||||
|
||||
<update id="updateDescriptionTags">
|
||||
UPDATE channels SET description = #{description}, tags = #{tags}
|
||||
<update id="updateChannel">
|
||||
UPDATE channels SET description = #{description}, tags = #{tags}, sort_order = #{sortOrder}
|
||||
WHERE id = #{id}
|
||||
</update>
|
||||
|
||||
|
||||
@@ -239,6 +239,14 @@
|
||||
ORDER BY r.name
|
||||
</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 ===== -->
|
||||
|
||||
<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"
|
||||
85
frontend/docs/design-concepts.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Tasteby Design Concept 후보
|
||||
|
||||
> Oracle의 Redwood처럼, Tasteby만의 디자인 언어를 정의하기 위한 컨셉 후보안.
|
||||
|
||||
---
|
||||
|
||||
## 1. Saffron (사프란) 🟠
|
||||
|
||||
따뜻한 금빛 오렌지. 고급스러운 미식 큐레이션 느낌.
|
||||
|
||||
| 역할 | 색상 | Hex |
|
||||
|------|------|-----|
|
||||
| Primary | 깊은 오렌지 | `#E8720C` |
|
||||
| Primary Light | 밝은 오렌지 | `#F59E3F` |
|
||||
| Primary Dark | 진한 오렌지 | `#C45A00` |
|
||||
| Accent | 골드 | `#F5A623` |
|
||||
| Accent Light | 라이트 골드 | `#FFD080` |
|
||||
| Background | 크림 화이트 | `#FFFAF5` |
|
||||
| Surface | 웜 그레이 | `#F7F3EF` |
|
||||
| Text Primary | 다크 브라운 | `#2C1810` |
|
||||
| Text Secondary | 미디엄 브라운 | `#7A6555` |
|
||||
|
||||
**키워드**: 프리미엄, 미식, 큐레이션, 따뜻함, 신뢰
|
||||
**어울리는 폰트**: Pretendard, Noto Sans KR (깔끔 + 웜톤 배경)
|
||||
|
||||
---
|
||||
|
||||
## 2. Gochujang (고추장) 🔴
|
||||
|
||||
한국 음식 DNA. 약간 붉은 오렌지 톤으로 대담하고 강렬.
|
||||
|
||||
| 역할 | 색상 | Hex |
|
||||
|------|------|-----|
|
||||
| Primary | 고추장 레드 | `#D94F30` |
|
||||
| Primary Light | 밝은 레드 | `#EF7B5A` |
|
||||
| Primary Dark | 진한 레드 | `#B53518` |
|
||||
| Accent | 따뜻한 오렌지 | `#FF8C42` |
|
||||
| Accent Light | 라이트 피치 | `#FFB88C` |
|
||||
| Background | 소프트 화이트 | `#FFFBF8` |
|
||||
| Surface | 웜 베이지 | `#F5F0EB` |
|
||||
| Text Primary | 차콜 | `#1A1A1A` |
|
||||
| Text Secondary | 다크 그레이 | `#666052` |
|
||||
|
||||
**키워드**: 한국, 활기, 식욕, 대담, 강렬
|
||||
**어울리는 폰트**: Spoqa Han Sans Neo, Pretendard (모던 + 힘있는)
|
||||
|
||||
---
|
||||
|
||||
## 3. Citrus (시트러스) 🍊
|
||||
|
||||
밝고 상큼한 비비드 오렌지. 현대적이고 친근한 느낌.
|
||||
|
||||
| 역할 | 색상 | Hex |
|
||||
|------|------|-----|
|
||||
| Primary | 비비드 오렌지 | `#FF6B2B` |
|
||||
| Primary Light | 라이트 오렌지 | `#FF9A6C` |
|
||||
| Primary Dark | 딥 오렌지 | `#E04D10` |
|
||||
| Accent | 피치 | `#FFB347` |
|
||||
| Accent Light | 소프트 피치 | `#FFD9A0` |
|
||||
| Background | 퓨어 화이트 | `#FFFFFF` |
|
||||
| Surface | 쿨 그레이 | `#F5F5F7` |
|
||||
| Text Primary | 뉴트럴 블랙 | `#171717` |
|
||||
| Text Secondary | 미디엄 그레이 | `#6B7280` |
|
||||
|
||||
**키워드**: 캐주얼, 트렌디, 활발, 친근, 상큼
|
||||
**어울리는 폰트**: Geist (현재 사용 중), Inter
|
||||
|
||||
---
|
||||
|
||||
## 현재 상태 (Before)
|
||||
|
||||
- Tailwind 기본 `orange` 팔레트 사용 (커스텀 없음)
|
||||
- 폰트: Geist (Google Fonts)
|
||||
- 다크모드: `prefers-color-scheme` 기반 자동 전환
|
||||
- 브랜드 컬러 정의 없음 — 컴포넌트마다 `orange-400~700` 개별 적용
|
||||
|
||||
## 적용 계획
|
||||
|
||||
1. 컨셉 선택
|
||||
2. CSS 변수로 디자인 토큰 정의 (`globals.css`)
|
||||
3. Tailwind v4 `@theme` 에 커스텀 컬러 등록
|
||||
4. 컴포넌트별 하드코딩된 orange → 시맨틱 토큰으로 교체
|
||||
5. 다크모드 팔레트 정의
|
||||
6. 폰트 교체 (필요시)
|
||||
7. 로고/아이콘 톤 맞춤
|
||||
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,34 @@ export default function AdminPage() {
|
||||
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>;
|
||||
return <div className="min-h-screen bg-background 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="min-h-screen bg-background 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>
|
||||
<a href="/" className="text-brand-600 hover:underline">메인으로 돌아가기</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 text-gray-900">
|
||||
<header className="bg-white border-b px-6 py-4">
|
||||
<div className="min-h-screen bg-background text-gray-900">
|
||||
<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 gap-3">
|
||||
<h1 className="text-xl font-bold">Tasteby Admin</h1>
|
||||
<img src="/logo-80h.png" alt="Tasteby" className="h-7" />
|
||||
<span className="text-xl font-bold text-gray-500">Admin</span>
|
||||
{!isAdmin && (
|
||||
<span className="px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded text-xs font-medium">읽기 전용</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isAdmin && <CacheFlushButton />}
|
||||
<a href="/" className="text-sm text-blue-600 hover:underline">
|
||||
<a href="/" className="text-sm text-brand-600 hover:underline">
|
||||
← 메인으로
|
||||
</a>
|
||||
</div>
|
||||
@@ -79,8 +80,8 @@ export default function AdminPage() {
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-4 py-2 text-sm rounded-t font-medium ${
|
||||
tab === t
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||
? "bg-brand-600 text-white"
|
||||
: "bg-brand-50 text-brand-700 hover:bg-brand-100"
|
||||
}`}
|
||||
>
|
||||
{t === "channels" ? "채널 관리" : t === "videos" ? "영상 관리" : t === "restaurants" ? "식당 관리" : t === "users" ? "유저 관리" : "데몬 설정"}
|
||||
@@ -134,10 +135,11 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
const [editingChannel, setEditingChannel] = useState<string | null>(null);
|
||||
const [editDesc, setEditDesc] = useState("");
|
||||
const [editTags, setEditTags] = useState("");
|
||||
const [editOrder, setEditOrder] = useState<number>(99);
|
||||
|
||||
const handleSaveChannel = async (id: string) => {
|
||||
try {
|
||||
await api.updateChannel(id, { description: editDesc, tags: editTags });
|
||||
await api.updateChannel(id, { description: editDesc, tags: editTags, sort_order: editOrder });
|
||||
setEditingChannel(null);
|
||||
load();
|
||||
} catch {
|
||||
@@ -170,46 +172,47 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
placeholder="YouTube Channel ID"
|
||||
value={newId}
|
||||
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
|
||||
placeholder="채널 이름"
|
||||
value={newName}
|
||||
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
|
||||
placeholder="제목 필터 (선택)"
|
||||
value={newFilter}
|
||||
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
|
||||
onClick={handleAdd}
|
||||
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>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="bg-surface rounded-lg shadow">
|
||||
<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>
|
||||
<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">제목 필터</th>
|
||||
<th className="text-left px-4 py-3">설명</th>
|
||||
<th className="text-left px-4 py-3">태그</th>
|
||||
<th className="text-center px-4 py-3">순서</th>
|
||||
<th className="text-right px-4 py-3">영상 수</th>
|
||||
{isAdmin && <th className="text-left px-4 py-3">액션</th>}
|
||||
<th className="text-left px-4 py-3">스캔 결과</th>
|
||||
@@ -217,14 +220,14 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 text-gray-500 font-mono text-xs">
|
||||
{ch.channel_id}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{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}
|
||||
</span>
|
||||
) : (
|
||||
@@ -234,11 +237,11 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<td className="px-4 py-3 text-xs">
|
||||
{editingChannel === ch.id ? (
|
||||
<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={() => {
|
||||
if (!isAdmin) return;
|
||||
setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || "");
|
||||
setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || ""); setEditOrder(ch.sort_order ?? 99);
|
||||
}}>{ch.description || <span className="text-gray-400">-</span>}</span>
|
||||
)}
|
||||
</td>
|
||||
@@ -246,17 +249,25 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
{editingChannel === ch.id ? (
|
||||
<div className="flex gap-1">
|
||||
<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="태그 (쉼표 구분)" />
|
||||
<button onClick={() => handleSaveChannel(ch.id)} className="text-blue-600 text-xs hover:underline">저장</button>
|
||||
className="border rounded px-2 py-1 text-xs w-40 bg-surface text-gray-900" placeholder="태그 (쉼표 구분)" />
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-500 cursor-pointer" onClick={() => {
|
||||
if (!isAdmin) return;
|
||||
setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || "");
|
||||
setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || ""); setEditOrder(ch.sort_order ?? 99);
|
||||
}}>{ch.tags ? ch.tags.split(",").map(t => t.trim()).join(", ") : <span className="text-gray-400">-</span>}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-xs">
|
||||
{editingChannel === ch.id ? (
|
||||
<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-surface text-gray-900" min={1} />
|
||||
) : (
|
||||
<span className="text-gray-500">{ch.sort_order ?? 99}</span>
|
||||
)}
|
||||
</td>
|
||||
<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>
|
||||
@@ -267,7 +278,7 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
{isAdmin && <td className="px-4 py-3 flex gap-3">
|
||||
<button
|
||||
onClick={() => handleScan(ch.channel_id)}
|
||||
className="text-blue-600 hover:underline text-sm"
|
||||
className="text-brand-600 hover:underline text-sm"
|
||||
>
|
||||
스캔
|
||||
</button>
|
||||
@@ -728,7 +739,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
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",
|
||||
error: "bg-red-100 text-red-800",
|
||||
skip: "bg-gray-100 text-gray-600",
|
||||
@@ -740,7 +751,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<select
|
||||
value={channelFilter}
|
||||
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>
|
||||
{channels.map((ch) => (
|
||||
@@ -750,7 +761,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<select
|
||||
value={statusFilter}
|
||||
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="pending">대기중</option>
|
||||
@@ -766,7 +777,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
value={titleSearch}
|
||||
onChange={(e) => { setTitleSearch(e.target.value); setPage(0); }}
|
||||
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 ? (
|
||||
<button
|
||||
@@ -798,7 +809,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<button
|
||||
onClick={() => startBulkStream("transcript")}
|
||||
disabled={bulkTranscripting || bulkExtracting}
|
||||
className="bg-orange-600 text-white px-4 py-2 rounded text-sm hover:bg-orange-700 disabled:opacity-50"
|
||||
className="bg-brand-600 text-white px-4 py-2 rounded text-sm hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{bulkTranscripting ? "자막 수집 중..." : "벌크 자막 수집"}
|
||||
</button>
|
||||
@@ -826,7 +837,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<button
|
||||
onClick={startRemapFoods}
|
||||
disabled={remappingFoods || bulkExtracting || bulkTranscripting || rebuildingVectors || remappingCuisine}
|
||||
className="bg-orange-600 text-white px-4 py-2 rounded text-sm hover:bg-orange-700 disabled:opacity-50"
|
||||
className="bg-brand-600 text-white px-4 py-2 rounded text-sm hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{remappingFoods ? "메뉴태그 재생성 중..." : "메뉴태그 재생성"}
|
||||
</button>
|
||||
@@ -839,7 +850,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<button
|
||||
onClick={() => startBulkStream("transcript", Array.from(selected))}
|
||||
disabled={bulkTranscripting || bulkExtracting}
|
||||
className="bg-orange-500 text-white px-4 py-2 rounded text-sm hover:bg-orange-600 disabled:opacity-50"
|
||||
className="bg-brand-500 text-white px-4 py-2 rounded text-sm hover:bg-brand-600 disabled:opacity-50"
|
||||
>
|
||||
선택 자막 수집 ({selected.size})
|
||||
</button>
|
||||
@@ -870,9 +881,9 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
</span>
|
||||
</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">
|
||||
<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>
|
||||
<th className="px-4 py-3 w-8">
|
||||
<input
|
||||
@@ -913,7 +924,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -936,7 +947,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<button
|
||||
onClick={() => handleSelectVideo(v)}
|
||||
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}
|
||||
>
|
||||
@@ -947,7 +958,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"}`}>
|
||||
{v.has_transcript ? "T" : "-"}
|
||||
</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" : "-"}
|
||||
</span>
|
||||
</td>
|
||||
@@ -1039,7 +1050,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
{/* 음식종류 재분류 진행 */}
|
||||
{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">
|
||||
음식종류 재분류 {remapProgress.current >= remapProgress.total ? "완료" : "진행 중"}
|
||||
</h4>
|
||||
@@ -1057,13 +1068,13 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
{/* 메뉴태그 재생성 진행 */}
|
||||
{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">
|
||||
메뉴태그 재생성 {foodsProgress.current >= foodsProgress.total ? "완료" : "진행 중"}
|
||||
</h4>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mb-2">
|
||||
<div
|
||||
className="bg-orange-500 h-2 rounded-full transition-all"
|
||||
className="bg-brand-500 h-2 rounded-full transition-all"
|
||||
style={{ width: `${foodsProgress.total ? (foodsProgress.current / foodsProgress.total) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -1075,7 +1086,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
{/* 벡터 재생성 진행 */}
|
||||
{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">
|
||||
벡터 재생성 {vectorProgress.phase === "done" ? "완료" : `(${vectorProgress.phase === "prepare" ? "데이터 준비" : "임베딩 저장"})`}
|
||||
</h4>
|
||||
@@ -1094,7 +1105,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
{/* 벌크 진행 패널 */}
|
||||
{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">
|
||||
<h4 className="font-semibold text-sm">
|
||||
{bulkProgress.label} ({bulkProgress.current}/{bulkProgress.total})
|
||||
@@ -1147,7 +1158,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<div className="mt-6 text-center text-gray-500 text-sm">로딩 중...</div>
|
||||
)}
|
||||
{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">
|
||||
{editingTitle ? (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
@@ -1168,7 +1179,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
finally { setSaving(false); }
|
||||
}}
|
||||
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>
|
||||
@@ -1181,7 +1192,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
</div>
|
||||
) : (
|
||||
<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}
|
||||
title={isAdmin ? "클릭하여 제목 수정" : undefined}
|
||||
>
|
||||
@@ -1280,34 +1291,34 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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 className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<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 className="flex gap-2">
|
||||
<button
|
||||
@@ -1350,7 +1361,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<textarea
|
||||
value={prompt}
|
||||
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}
|
||||
placeholder="프롬프트 템플릿 ({title}, {transcript} 변수 사용)"
|
||||
/>
|
||||
@@ -1364,39 +1375,39 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<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 className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<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>
|
||||
<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 className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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 className="flex gap-2">
|
||||
<button
|
||||
@@ -1427,7 +1438,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
finally { setSaving(false); }
|
||||
}}
|
||||
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 ? "저장 중..." : "저장"}
|
||||
</button>
|
||||
@@ -1441,7 +1452,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
</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 ? () => {
|
||||
let evalText = "";
|
||||
if (typeof r.evaluation === "object" && r.evaluation) {
|
||||
@@ -1505,7 +1516,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
{r.foods_mentioned.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{r.foods_mentioned.map((f, j) => (
|
||||
<span key={j} className="px-1.5 py-0.5 bg-orange-50 text-orange-700 rounded text-xs">{f}</span>
|
||||
<span key={j} className="px-1.5 py-0.5 bg-brand-50 text-brand-700 rounded text-xs">{f}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -1535,7 +1546,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<select
|
||||
value={transcriptMode}
|
||||
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="manual">수동 자막만</option>
|
||||
@@ -1556,7 +1567,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
}
|
||||
}}
|
||||
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 ? "다시 가져오기" : "트랜스크립트 가져오기"}
|
||||
</button>
|
||||
@@ -1697,7 +1708,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
value={nameSearch}
|
||||
onChange={(e) => { setNameSearch(e.target.value); setPage(0); }}
|
||||
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 ? (
|
||||
<button
|
||||
@@ -1759,10 +1770,42 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
finally { setBulkTabling(false); load(); }
|
||||
}}
|
||||
disabled={bulkTabling}
|
||||
className="px-3 py-1.5 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50"
|
||||
className="px-3 py-1.5 text-xs bg-brand-500 text-white rounded hover:bg-brand-600 disabled:opacity-50"
|
||||
>
|
||||
{bulkTabling ? `테이블링 검색 중 (${bulkTablingProgress.current}/${bulkTablingProgress.total})` : "벌크 테이블링 연결"}
|
||||
</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
|
||||
onClick={async () => {
|
||||
const pending = await fetch(`/api/restaurants/catchtable-pending`, {
|
||||
@@ -1815,13 +1858,13 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
</span>
|
||||
</div>
|
||||
{bulkTabling && bulkTablingProgress.name && (
|
||||
<div className="bg-orange-50 rounded p-3 mb-4 text-sm">
|
||||
<div className="bg-brand-50 rounded p-3 mb-4 text-sm">
|
||||
<div className="flex justify-between mb-1">
|
||||
<span>{bulkTablingProgress.current}/{bulkTablingProgress.total} - {bulkTablingProgress.name}</span>
|
||||
<span className="text-xs text-gray-500">연결: {bulkTablingProgress.linked} / 미발견: {bulkTablingProgress.notFound}</span>
|
||||
</div>
|
||||
<div className="w-full bg-orange-200 rounded-full h-1.5">
|
||||
<div className="bg-orange-500 h-1.5 rounded-full transition-all" style={{ width: `${(bulkTablingProgress.current / bulkTablingProgress.total) * 100}%` }} />
|
||||
<div className="w-full bg-brand-200 rounded-full h-1.5">
|
||||
<div className="bg-brand-500 h-1.5 rounded-full transition-all" style={{ width: `${(bulkTablingProgress.current / bulkTablingProgress.total) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1837,9 +1880,9 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
</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">
|
||||
<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>
|
||||
<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>
|
||||
@@ -1854,7 +1897,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<tr
|
||||
key={r.id}
|
||||
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 text-gray-600 text-xs">{r.region || "-"}</td>
|
||||
@@ -1899,7 +1942,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
{/* 식당 상세/수정 패널 */}
|
||||
{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">
|
||||
<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>
|
||||
@@ -1921,7 +1964,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<input
|
||||
value={editForm[key] || ""}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
@@ -1939,7 +1982,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
href={`https://www.google.com/maps/place/?q=place_id:${selected.google_place_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-xs"
|
||||
className="text-brand-600 hover:underline text-xs"
|
||||
>
|
||||
Google Maps에서 보기
|
||||
</a>
|
||||
@@ -1954,7 +1997,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<span className="text-xs text-gray-400">검색완료-없음</span>
|
||||
) : selected.tabling_url ? (
|
||||
<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>
|
||||
)}
|
||||
@@ -1977,7 +2020,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
finally { setTablingSearching(false); }
|
||||
}}
|
||||
disabled={tablingSearching}
|
||||
className="px-2 py-0.5 text-[11px] bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50"
|
||||
className="px-2 py-0.5 text-[11px] bg-brand-500 text-white rounded hover:bg-brand-600 disabled:opacity-50"
|
||||
>
|
||||
{tablingSearching ? "검색 중..." : "테이블링 검색"}
|
||||
</button>
|
||||
@@ -2005,7 +2048,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<span className="text-xs text-gray-400">검색완료-없음</span>
|
||||
) : selected.catchtable_url ? (
|
||||
<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>
|
||||
)}
|
||||
@@ -2057,7 +2100,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">
|
||||
{v.channel_name}
|
||||
</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}
|
||||
</a>
|
||||
<span className="text-gray-400 shrink-0">{v.published_at?.slice(0, 10)}</span>
|
||||
@@ -2071,7 +2114,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
{isAdmin && <button
|
||||
onClick={handleSave}
|
||||
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 ? "저장 중..." : "저장"}
|
||||
</button>}
|
||||
@@ -2182,9 +2225,9 @@ function UsersPanel() {
|
||||
<h2 className="text-lg font-bold">유저 관리 ({total}명)</h2>
|
||||
|
||||
{/* 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">
|
||||
<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>
|
||||
<th className="text-left px-4 py-2">사용자</th>
|
||||
<th className="text-left px-4 py-2">이메일</th>
|
||||
@@ -2200,8 +2243,8 @@ function UsersPanel() {
|
||||
onClick={() => handleSelectUser(u)}
|
||||
className={`border-t cursor-pointer transition-colors ${
|
||||
selectedUser?.id === u.id
|
||||
? "bg-blue-50"
|
||||
: "hover:bg-gray-50"
|
||||
? "bg-brand-50"
|
||||
: "hover:bg-brand-50/50"
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-2">
|
||||
@@ -2234,7 +2277,7 @@ function UsersPanel() {
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{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}
|
||||
</span>
|
||||
) : (
|
||||
@@ -2275,7 +2318,7 @@ function UsersPanel() {
|
||||
|
||||
{/* Selected User Detail */}
|
||||
{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">
|
||||
{selectedUser.avatar_url ? (
|
||||
<img
|
||||
@@ -2342,7 +2385,7 @@ function UsersPanel() {
|
||||
|
||||
{/* Reviews */}
|
||||
<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})
|
||||
</h3>
|
||||
{reviews.length === 0 ? (
|
||||
@@ -2466,7 +2509,7 @@ function DaemonPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 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>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
데몬이 실행 중일 때, 아래 설정에 따라 자동으로 채널 스캔 및 영상 처리를 수행합니다.
|
||||
@@ -2559,7 +2602,7 @@ function DaemonPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<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"
|
||||
className="px-4 py-2 bg-brand-600 text-white text-sm rounded hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "저장 중..." : "설정 저장"}
|
||||
</button>
|
||||
@@ -2568,7 +2611,7 @@ function DaemonPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
스케줄과 관계없이 즉시 실행합니다. 처리 시간이 걸릴 수 있습니다.
|
||||
|
||||
@@ -1,23 +1,52 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Force light mode: dark: classes only activate with .dark ancestor */
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--background: #FFFAF5;
|
||||
--foreground: #171717;
|
||||
color-scheme: light dark;
|
||||
--surface: #FFFFFF;
|
||||
--brand-50: #FFF8F0;
|
||||
--brand-100: #FFEDD5;
|
||||
--brand-200: #FFD6A5;
|
||||
--brand-300: #FFBC72;
|
||||
--brand-400: #F5A623;
|
||||
--brand-500: #F59E3F;
|
||||
--brand-600: #E8720C;
|
||||
--brand-700: #C45A00;
|
||||
--brand-800: #9A4500;
|
||||
--brand-900: #6B3000;
|
||||
--brand-950: #3D1A00;
|
||||
color-scheme: only light !important;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist);
|
||||
--color-surface: var(--surface);
|
||||
--color-brand-50: var(--brand-50);
|
||||
--color-brand-100: var(--brand-100);
|
||||
--color-brand-200: var(--brand-200);
|
||||
--color-brand-300: var(--brand-300);
|
||||
--color-brand-400: var(--brand-400);
|
||||
--color-brand-500: var(--brand-500);
|
||||
--color-brand-600: var(--brand-600);
|
||||
--color-brand-700: var(--brand-700);
|
||||
--color-brand-800: var(--brand-800);
|
||||
--color-brand-900: var(--brand-900);
|
||||
--color-brand-950: var(--brand-950);
|
||||
--font-sans: var(--font-pretendard), var(--font-geist), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
/* Dark mode CSS vars (disabled — activate by adding .dark class to <html>) */
|
||||
/*
|
||||
.dark {
|
||||
--background: #12100E;
|
||||
--foreground: #ededed;
|
||||
--surface: #1C1916;
|
||||
}
|
||||
*/
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
@@ -43,6 +72,31 @@ html, body, #__next {
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
/* Hide scrollbar but keep scrolling */
|
||||
@layer utilities {
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none !important;
|
||||
scrollbar-width: none !important;
|
||||
overflow: -moz-scrollbars-none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
@@ -8,6 +9,14 @@ const geist = Geist({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const pretendard = localFont({
|
||||
src: [
|
||||
{ path: "../fonts/PretendardVariable.woff2", style: "normal" },
|
||||
],
|
||||
variable: "--font-pretendard",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tasteby - YouTube Restaurant Map",
|
||||
description: "YouTube food channel restaurant map service",
|
||||
@@ -19,8 +28,15 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="ko" className="dark:bg-gray-950" suppressHydrationWarning>
|
||||
<body className={`${geist.variable} font-sans antialiased`}>
|
||||
<html lang="ko" className="bg-background" style={{ colorScheme: "only light" }} suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="color-scheme" content="only light" />
|
||||
<meta name="supported-color-schemes" 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`}>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -13,6 +13,45 @@ import RestaurantDetail from "@/components/RestaurantDetail";
|
||||
import MyReviewsList from "@/components/MyReviewsList";
|
||||
import BottomSheet from "@/components/BottomSheet";
|
||||
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||
import Icon from "@/components/Icon";
|
||||
|
||||
function useDragScroll() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const drag = useRef({ active: false, startX: 0, sl: 0, moved: false });
|
||||
|
||||
const onMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
drag.current = { active: true, startX: e.clientX, sl: el.scrollLeft, moved: false };
|
||||
el.style.cursor = "grabbing";
|
||||
}, []);
|
||||
|
||||
const onMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
const d = drag.current;
|
||||
if (!d.active) return;
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const dx = e.clientX - d.startX;
|
||||
if (Math.abs(dx) > 5) d.moved = true;
|
||||
el.scrollLeft = d.sl - dx;
|
||||
}, []);
|
||||
|
||||
const onMouseUp = useCallback(() => {
|
||||
drag.current.active = false;
|
||||
const el = ref.current;
|
||||
if (el) el.style.cursor = "grab";
|
||||
}, []);
|
||||
|
||||
const onClickCapture = useCallback((e: React.MouseEvent) => {
|
||||
if (drag.current.moved) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
drag.current.moved = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { ref, onMouseDown, onMouseMove, onMouseUp, onMouseLeave: onMouseUp, onClickCapture, style: { cursor: "grab" } as const };
|
||||
}
|
||||
|
||||
const CUISINE_TAXONOMY: { category: string; items: string[] }[] = [
|
||||
{ category: "한식", items: ["백반/한정식", "국밥/해장국", "찌개/전골/탕", "삼겹살/돼지구이", "소고기/한우구이", "곱창/막창", "닭/오리구이", "족발/보쌈", "회/횟집", "해산물", "분식", "면", "죽/죽집", "순대/순대국", "장어/민물", "주점/포차", "파인다이닝/코스"] },
|
||||
@@ -157,6 +196,8 @@ export default function Home() {
|
||||
const [isSearchResult, setIsSearchResult] = useState(false);
|
||||
const [resetCount, setResetCount] = useState(0);
|
||||
const geoApplied = useRef(false);
|
||||
const dd = useDragScroll();
|
||||
const dm = useDragScroll();
|
||||
|
||||
const regionTree = useMemo(() => buildRegionTree(restaurants), [restaurants]);
|
||||
const countries = useMemo(() => [...regionTree.keys()].sort(), [regionTree]);
|
||||
@@ -223,16 +264,16 @@ export default function Home() {
|
||||
api.recordVisit().then(() => api.getVisits().then(setVisits)).catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Load restaurants on mount and when channel filter changes
|
||||
// Load all restaurants on mount
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setIsSearchResult(false);
|
||||
api
|
||||
.getRestaurants({ limit: 500, channel: channelFilter || undefined })
|
||||
.getRestaurants({ limit: 500 })
|
||||
.then(setRestaurants)
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, [channelFilter]);
|
||||
}, []);
|
||||
|
||||
// Auto-select region from user's geolocation (once)
|
||||
useEffect(() => {
|
||||
@@ -316,6 +357,7 @@ export default function Home() {
|
||||
setCountryFilter(country);
|
||||
setCityFilter("");
|
||||
setDistrictFilter("");
|
||||
setBoundsFilterOn(false);
|
||||
if (!country) { setRegionFlyTo(null); return; }
|
||||
const matched = restaurants.filter((r) => {
|
||||
const p = parseRegion(r.region);
|
||||
@@ -413,7 +455,7 @@ export default function Home() {
|
||||
setShowFavorites(false);
|
||||
setShowMyReviews(false);
|
||||
setMyReviews([]);
|
||||
api.getRestaurants({ limit: 500, channel: channelFilter || undefined }).then(setRestaurants);
|
||||
api.getRestaurants({ limit: 500 }).then(setRestaurants);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -439,7 +481,7 @@ export default function Home() {
|
||||
} catch { /* ignore */ }
|
||||
// 프로필에서는 식당 목록을 원래대로 복원
|
||||
if (showFavorites) {
|
||||
api.getRestaurants({ limit: 500, channel: channelFilter || undefined }).then(setRestaurants);
|
||||
api.getRestaurants({ limit: 500 }).then(setRestaurants);
|
||||
}
|
||||
} else {
|
||||
// 홈 / 식당 목록 - 항상 전체 식당 목록으로 복원
|
||||
@@ -448,16 +490,16 @@ export default function Home() {
|
||||
setShowMyReviews(false);
|
||||
setMyReviews([]);
|
||||
if (needReload) {
|
||||
const data = await api.getRestaurants({ limit: 500, channel: channelFilter || undefined });
|
||||
const data = await api.getRestaurants({ limit: 500 });
|
||||
setRestaurants(data);
|
||||
}
|
||||
}
|
||||
}, [user, showFavorites, showMyReviews, channelFilter, mobileTab, handleReset]);
|
||||
}, [user, showFavorites, showMyReviews, mobileTab, handleReset]);
|
||||
|
||||
const handleToggleFavorites = async () => {
|
||||
if (showFavorites) {
|
||||
setShowFavorites(false);
|
||||
const data = await api.getRestaurants({ limit: 500, channel: channelFilter || undefined });
|
||||
const data = await api.getRestaurants({ limit: 500 });
|
||||
setRestaurants(data);
|
||||
} else {
|
||||
try {
|
||||
@@ -542,156 +584,96 @@ export default function Home() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-white dark:bg-gray-950">
|
||||
<div className="h-screen flex flex-col bg-background">
|
||||
{/* ── Header row 1: Logo + User ── */}
|
||||
<header className="bg-white/80 dark:bg-gray-900/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">
|
||||
<button onClick={handleReset} className="text-lg font-bold whitespace-nowrap">
|
||||
Tasteby
|
||||
<button onClick={handleReset} className="shrink-0">
|
||||
<img src="/logo-80h.png" alt="Tasteby" className="h-7 md:h-8" />
|
||||
</button>
|
||||
|
||||
{/* Desktop: search + filters — two rows */}
|
||||
<div className="hidden md:flex flex-col gap-2.5 mx-6">
|
||||
{/* Row 1: Search + dropdown filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-96 shrink-0">
|
||||
{/* Desktop: search + filters */}
|
||||
<div className="hidden md:flex flex-col gap-2 mx-6 flex-1 min-w-0">
|
||||
{/* Row 1: Search + actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-80 shrink-0">
|
||||
<SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} />
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="p-1.5 text-gray-400 dark:text-gray-500 hover:text-orange-500 dark:hover:text-orange-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="초기화"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-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>
|
||||
</button>
|
||||
<select
|
||||
value={cuisineFilter}
|
||||
onChange={(e) => setCuisineFilter(e.target.value)}
|
||||
className="border dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">🍽 장르</option>
|
||||
{CUISINE_TAXONOMY.map((g) => (
|
||||
<optgroup key={g.category} label={`── ${g.category} ──`}>
|
||||
<option value={g.category}>🍽 {g.category} 전체</option>
|
||||
{g.items.map((item) => (
|
||||
<option key={`${g.category}|${item}`} value={`${g.category}|${item}`}>
|
||||
{item}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={priceFilter}
|
||||
onChange={(e) => setPriceFilter(e.target.value)}
|
||||
className="border dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">💰 가격</option>
|
||||
{PRICE_GROUPS.map((g) => (
|
||||
<option key={g.label} value={g.label}>{g.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* Row 2: Region filters + Toggle buttons + count */}
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={countryFilter}
|
||||
onChange={(e) => handleCountryChange(e.target.value)}
|
||||
className="border dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 dark:bg-gray-800"
|
||||
>
|
||||
<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 dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 dark:bg-gray-800"
|
||||
>
|
||||
<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 dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">🏘 전체 구/군</option>
|
||||
{districts.map((d) => (
|
||||
<option key={d} value={d}>🏘 {d}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<div className="w-px h-5 bg-gray-200 dark:bg-gray-700" />
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = !boundsFilterOn;
|
||||
setBoundsFilterOn(next);
|
||||
if (next) {
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }),
|
||||
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 }),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
} else {
|
||||
setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 });
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-1.5 text-sm border rounded-lg transition-colors ${
|
||||
boundsFilterOn
|
||||
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400"
|
||||
: "hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400"
|
||||
}`}
|
||||
title="내 위치 주변 식당만 표시"
|
||||
>
|
||||
{boundsFilterOn ? "📍 내위치 ON" : "📍 내위치"}
|
||||
<Icon name="refresh" size={18} />
|
||||
</button>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-500 tabular-nums">
|
||||
{filteredRestaurants.length}개
|
||||
</span>
|
||||
<div className="w-px h-4 bg-gray-200 dark:bg-gray-700" />
|
||||
<button
|
||||
onClick={() => setViewMode(viewMode === "map" ? "list" : "map")}
|
||||
className="px-3 py-1.5 text-sm border rounded-lg transition-colors hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400"
|
||||
title={viewMode === "map" ? "리스트 우선" : "지도 우선"}
|
||||
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
|
||||
viewMode === "map"
|
||||
? "bg-blue-50 dark:bg-blue-900/30 border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-blue-300 hover:text-blue-500"
|
||||
}`}
|
||||
>
|
||||
{viewMode === "map" ? "🗺 지도" : "☰ 리스트"}
|
||||
{viewMode === "map" ? "🗺 지도우선" : "☰ 목록우선"}
|
||||
</button>
|
||||
{user && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleToggleFavorites}
|
||||
className={`px-3.5 py-1.5 text-sm rounded-full border transition-colors ${
|
||||
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
|
||||
showFavorites
|
||||
? "bg-rose-50 dark:bg-rose-900/30 border-rose-300 dark:border-rose-700 text-rose-600 dark:text-rose-400"
|
||||
: "border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-100"
|
||||
: "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
|
||||
onClick={handleToggleMyReviews}
|
||||
className={`px-3.5 py-1.5 text-sm rounded-full border transition-colors ${
|
||||
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
|
||||
showMyReviews
|
||||
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400"
|
||||
: "border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-100"
|
||||
? "bg-brand-50 dark:bg-brand-900/30 border-brand-300 dark:border-brand-700 text-brand-600 dark:text-brand-400"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-brand-300 hover:text-brand-500"
|
||||
}`}
|
||||
>
|
||||
{showMyReviews ? "✎ 내 리뷰" : "✎ 리뷰"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<span className="text-sm text-gray-500 whitespace-nowrap">
|
||||
{filteredRestaurants.length}개
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
{/* Desktop user area */}
|
||||
{authLoading ? null : user ? (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{user.avatar_url ? (
|
||||
<img src={user.avatar_url} alt="" className="w-7 h-7 rounded-full border border-gray-200" />
|
||||
) : (
|
||||
<div className="w-7 h-7 rounded-full bg-brand-100 text-brand-700 flex items-center justify-center text-xs font-semibold border border-brand-200">
|
||||
{(user.nickname || user.email || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-300 max-w-[80px] truncate">
|
||||
{user.nickname || user.email}
|
||||
</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="px-2 py-0.5 text-[10px] text-gray-500 dark:text-gray-500 border border-gray-200 dark:border-gray-700 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="shrink-0">
|
||||
<LoginMenu onGoogleSuccess={(credential) => login(credential).catch(console.error)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Row 3: Channel cards (toggle filter) - max 4 visible, scroll for rest */}
|
||||
{/* Row 2: Channel cards (toggle filter) */}
|
||||
{!isSearchResult && channels.length > 0 && (
|
||||
<div className="overflow-x-auto scrollbar-hide" style={{ maxWidth: `${4 * 200 + 3 * 8}px` }}>
|
||||
<div ref={dd.ref} onMouseDown={dd.onMouseDown} onMouseMove={dd.onMouseMove} onMouseUp={dd.onMouseUp} onMouseLeave={dd.onMouseLeave} onClickCapture={dd.onClickCapture} style={dd.style} className="overflow-x-auto scrollbar-hide select-none">
|
||||
<div className="flex gap-2">
|
||||
{channels.map((ch) => (
|
||||
<button
|
||||
@@ -703,49 +685,218 @@ export default function Home() {
|
||||
}}
|
||||
className={`shrink-0 flex items-center gap-2 rounded-lg px-3 py-1.5 border transition-all text-left ${
|
||||
channelFilter === ch.channel_name
|
||||
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700"
|
||||
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-orange-200 dark:hover:border-orange-800"
|
||||
? "bg-brand-50 dark:bg-brand-900/30 border-brand-300 dark:border-brand-700"
|
||||
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-brand-200 dark:hover:border-brand-800"
|
||||
}`}
|
||||
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">
|
||||
<p className={`text-xs font-semibold truncate ${
|
||||
channelFilter === ch.channel_name ? "text-orange-600 dark:text-orange-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.description && <p className="text-[10px] text-gray-400 dark:text-gray-500 truncate">{ch.description}</p>}
|
||||
{ch.description && <p className="text-[10px] text-gray-500 dark:text-gray-500 truncate">{ch.description}</p>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Row 3: Filters — grouped by category */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{/* 음식 필터 그룹 */}
|
||||
<div className="flex items-center gap-1.5 bg-gray-50 dark:bg-gray-800/50 rounded-lg px-2 py-1">
|
||||
<span className="text-gray-500 dark:text-gray-500 text-[10px] font-medium shrink-0">음식</span>
|
||||
<select
|
||||
value={cuisineFilter}
|
||||
onChange={(e) => { setCuisineFilter(e.target.value); if (e.target.value) setBoundsFilterOn(false); }}
|
||||
className={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
|
||||
cuisineFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<option value="">장르</option>
|
||||
{CUISINE_TAXONOMY.map((g) => (
|
||||
<optgroup key={g.category} label={`── ${g.category} ──`}>
|
||||
<option value={g.category}>{g.category} 전체</option>
|
||||
{g.items.map((item) => (
|
||||
<option key={`${g.category}|${item}`} value={`${g.category}|${item}`}>
|
||||
{item}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<div className="w-px h-3 bg-gray-200 dark:bg-gray-700" />
|
||||
<select
|
||||
value={priceFilter}
|
||||
onChange={(e) => { setPriceFilter(e.target.value); if (e.target.value) setBoundsFilterOn(false); }}
|
||||
className={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
|
||||
priceFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<option value="">가격</option>
|
||||
{PRICE_GROUPS.map((g) => (
|
||||
<option key={g.label} value={g.label}>{g.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{(cuisineFilter || priceFilter) && (
|
||||
<button
|
||||
onClick={() => { setCuisineFilter(""); setPriceFilter(""); }}
|
||||
className="text-gray-400 hover:text-brand-500 transition-colors"
|
||||
title="음식 필터 초기화"
|
||||
>
|
||||
<Icon name="close" size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 지역 필터 그룹 */}
|
||||
<div className="flex items-center gap-1.5 bg-gray-50 dark:bg-gray-800/50 rounded-lg px-2 py-1">
|
||||
<span className="text-gray-500 dark:text-gray-500 text-[10px] font-medium shrink-0">지역</span>
|
||||
<select
|
||||
value={countryFilter}
|
||||
onChange={(e) => handleCountryChange(e.target.value)}
|
||||
className={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
|
||||
countryFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<option value="">나라</option>
|
||||
{countries.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
{countryFilter && cities.length > 0 && (
|
||||
<>
|
||||
<div className="w-px h-3 bg-gray-200 dark:bg-gray-700" />
|
||||
<select
|
||||
value={cityFilter}
|
||||
onChange={(e) => handleCityChange(e.target.value)}
|
||||
className={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
|
||||
cityFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<option value="">시/도</option>
|
||||
{cities.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
{cityFilter && districts.length > 0 && (
|
||||
<>
|
||||
<div className="w-px h-3 bg-gray-200 dark:bg-gray-700" />
|
||||
<select
|
||||
value={districtFilter}
|
||||
onChange={(e) => handleDistrictChange(e.target.value)}
|
||||
className={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
|
||||
districtFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<option value="">구/군</option>
|
||||
{districts.map((d) => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
{countryFilter && (
|
||||
<button
|
||||
onClick={() => { setCountryFilter(""); setCityFilter(""); setDistrictFilter(""); setRegionFlyTo(null); }}
|
||||
className="text-gray-400 hover:text-brand-500 transition-colors"
|
||||
title="지역 필터 초기화"
|
||||
>
|
||||
<Icon name="close" size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 필터 전체 해제 */}
|
||||
{(channelFilter || cuisineFilter || priceFilter || countryFilter) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setChannelFilter("");
|
||||
setCuisineFilter("");
|
||||
setPriceFilter("");
|
||||
setCountryFilter("");
|
||||
setCityFilter("");
|
||||
setDistrictFilter("");
|
||||
setRegionFlyTo(null);
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<Icon name="close" size={12} />
|
||||
<span>전체보기</span>
|
||||
</button>
|
||||
)}
|
||||
{/* 내위치 토글 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = !boundsFilterOn;
|
||||
setBoundsFilterOn(next);
|
||||
if (next) {
|
||||
// 내위치 ON 시 다른 필터 초기화
|
||||
setCuisineFilter("");
|
||||
setPriceFilter("");
|
||||
setCountryFilter("");
|
||||
setCityFilter("");
|
||||
setDistrictFilter("");
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }),
|
||||
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 }),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
} else {
|
||||
setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 });
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`flex items-center gap-1 rounded-lg px-2 py-1 transition-colors ${
|
||||
boundsFilterOn
|
||||
? "bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400"
|
||||
: "bg-gray-50 dark:bg-gray-800/50 text-gray-500 dark:text-gray-400 hover:text-brand-500"
|
||||
}`}
|
||||
title="내 위치 주변 식당만 표시"
|
||||
>
|
||||
<Icon name="location_on" size={14} />
|
||||
<span>{boundsFilterOn ? "내위치 ON" : "내위치"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User area */}
|
||||
<div className="shrink-0 flex items-center gap-3 ml-auto">
|
||||
{/* User area (mobile only - desktop moved to Row 1) */}
|
||||
<div className="shrink-0 flex items-center gap-2 ml-auto md:hidden">
|
||||
{user && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleToggleFavorites}
|
||||
className={`px-2 py-0.5 text-[10px] rounded-full border transition-colors ${
|
||||
showFavorites
|
||||
? "bg-rose-50 dark:bg-rose-900/30 border-rose-300 dark:border-rose-700 text-rose-600 dark:text-rose-400"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<Icon name="favorite" size={14} filled={showFavorites} className="mr-0.5" />{showFavorites ? "찜" : "찜"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleMyReviews}
|
||||
className={`px-2 py-0.5 text-[10px] rounded-full border transition-colors ${
|
||||
showMyReviews
|
||||
? "bg-brand-50 dark:bg-brand-900/30 border-brand-300 dark:border-brand-700 text-brand-600 dark:text-brand-400"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{showMyReviews ? "✎ 리뷰" : "✎ 리뷰"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{authLoading ? null : user ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center">
|
||||
{user.avatar_url ? (
|
||||
<img
|
||||
src={user.avatar_url}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full border border-gray-200"
|
||||
/>
|
||||
<img src={user.avatar_url} alt="" className="w-7 h-7 rounded-full border border-gray-200" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-orange-100 text-orange-700 flex items-center justify-center text-sm font-semibold border border-orange-200">
|
||||
<div className="w-7 h-7 rounded-full bg-brand-100 text-brand-700 flex items-center justify-center text-xs font-semibold border border-brand-200">
|
||||
{(user.nickname || user.email || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="hidden sm:inline text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{user.nickname || user.email}
|
||||
</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="ml-1 px-2.5 py-1 text-xs text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<LoginMenu onGoogleSuccess={(credential) => login(credential).catch(console.error)} />
|
||||
@@ -758,8 +909,8 @@ export default function Home() {
|
||||
{/* Row 1: Search */}
|
||||
<SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} />
|
||||
{/* Channel cards - toggle filter */}
|
||||
{mobileTab === "home" && !isSearchResult && channels.length > 0 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 -mx-1 px-1 scrollbar-hide">
|
||||
{(mobileTab === "home" || mobileTab === "list" || mobileTab === "nearby") && !isSearchResult && channels.length > 0 && (
|
||||
<div ref={dm.ref} onMouseDown={dm.onMouseDown} onMouseMove={dm.onMouseMove} onMouseUp={dm.onMouseUp} onMouseLeave={dm.onMouseLeave} onClickCapture={dm.onClickCapture} style={dm.style} className="flex gap-2 overflow-x-auto pb-1 -mx-1 px-1 scrollbar-hide select-none">
|
||||
{channels.map((ch) => (
|
||||
<button
|
||||
key={ch.id}
|
||||
@@ -770,15 +921,15 @@ export default function Home() {
|
||||
}}
|
||||
className={`shrink-0 rounded-xl px-3 py-2 text-left border transition-all ${
|
||||
channelFilter === ch.channel_name
|
||||
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700"
|
||||
? "bg-brand-50 dark:bg-brand-900/30 border-brand-300 dark:border-brand-700"
|
||||
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
|
||||
}`}
|
||||
style={{ minWidth: "140px", maxWidth: "170px" }}
|
||||
>
|
||||
<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 ${
|
||||
channelFilter === ch.channel_name ? "text-orange-600 dark:text-orange-400" : "dark:text-gray-200"
|
||||
channelFilter === ch.channel_name ? "text-brand-600 dark:text-brand-400" : "dark:text-gray-200"
|
||||
}`}>{ch.channel_name}</p>
|
||||
</div>
|
||||
{ch.description && <p className="text-[10px] text-gray-500 dark:text-gray-400 truncate mt-0.5">{ch.description}</p>}
|
||||
@@ -794,127 +945,127 @@ export default function Home() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 2: Toolbar */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowMobileFilters(!showMobileFilters)}
|
||||
className={`px-3 py-1.5 text-xs border rounded-lg transition-colors relative ${
|
||||
showMobileFilters || channelFilter || cuisineFilter || priceFilter || countryFilter
|
||||
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400"
|
||||
: "text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{showMobileFilters ? "✕ 닫기" : "🔽 필터"}
|
||||
{!showMobileFilters && (channelFilter || cuisineFilter || priceFilter || countryFilter) && (
|
||||
<span className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-orange-500 text-white rounded-full text-[9px] flex items-center justify-center">
|
||||
{[channelFilter, cuisineFilter, priceFilter, countryFilter].filter(Boolean).length}
|
||||
</span>
|
||||
{/* Row 2: Filters - always visible, 2 lines */}
|
||||
<div className="space-y-1.5">
|
||||
{/* Line 1: 음식 장르 + 가격 + 결과수 */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<select
|
||||
value={cuisineFilter}
|
||||
onChange={(e) => { setCuisineFilter(e.target.value); if (e.target.value) setBoundsFilterOn(false); }}
|
||||
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
|
||||
cuisineFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<option value="">🍽 장르</option>
|
||||
{CUISINE_TAXONOMY.map((g) => (
|
||||
<optgroup key={g.category} label={`── ${g.category} ──`}>
|
||||
<option value={g.category}>{g.category} 전체</option>
|
||||
{g.items.map((item) => (
|
||||
<option key={`${g.category}|${item}`} value={`${g.category}|${item}`}>
|
||||
{item}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={priceFilter}
|
||||
onChange={(e) => { setPriceFilter(e.target.value); if (e.target.value) setBoundsFilterOn(false); }}
|
||||
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
|
||||
priceFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<option value="">💰 가격</option>
|
||||
{PRICE_GROUPS.map((g) => (
|
||||
<option key={g.label} value={g.label}>{g.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{(cuisineFilter || priceFilter) && (
|
||||
<button onClick={() => { setCuisineFilter(""); setPriceFilter(""); }} className="text-gray-400 hover:text-brand-500">
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
<span className="text-xs text-gray-400 ml-auto">
|
||||
{filteredRestaurants.length}개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Collapsible filter panel */}
|
||||
{showMobileFilters && (
|
||||
<div className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-md rounded-xl p-3.5 space-y-3 border border-white/50 dark:border-gray-700/50 shadow-sm">
|
||||
{/* Dropdown filters */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[10px] text-gray-400 ml-auto tabular-nums">{filteredRestaurants.length}개</span>
|
||||
</div>
|
||||
{/* Line 2: 나라 + 시 + 구 + 내위치 */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<select
|
||||
value={countryFilter}
|
||||
onChange={(e) => handleCountryChange(e.target.value)}
|
||||
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
|
||||
countryFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<option value="">🌍 나라</option>
|
||||
{countries.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
{countryFilter && cities.length > 0 && (
|
||||
<select
|
||||
value={cuisineFilter}
|
||||
onChange={(e) => setCuisineFilter(e.target.value)}
|
||||
className="border dark:border-gray-700 rounded-lg px-2.5 py-1.5 text-xs text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800"
|
||||
>
|
||||
<option value="">🍽 장르</option>
|
||||
{CUISINE_TAXONOMY.map((g) => (
|
||||
<optgroup key={g.category} label={`── ${g.category} ──`}>
|
||||
<option value={g.category}>🍽 {g.category} 전체</option>
|
||||
{g.items.map((item) => (
|
||||
<option key={`${g.category}|${item}`} value={`${g.category}|${item}`}>
|
||||
{item}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={priceFilter}
|
||||
onChange={(e) => setPriceFilter(e.target.value)}
|
||||
className="border dark:border-gray-700 rounded-lg px-2.5 py-1.5 text-xs text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800"
|
||||
>
|
||||
<option value="">💰 가격</option>
|
||||
{PRICE_GROUPS.map((g) => (
|
||||
<option key={g.label} value={g.label}>💰 {g.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* Region filters */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<select
|
||||
value={countryFilter}
|
||||
onChange={(e) => handleCountryChange(e.target.value)}
|
||||
className="border dark:border-gray-700 rounded-lg px-2.5 py-1.5 text-xs text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800"
|
||||
>
|
||||
<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 dark:border-gray-700 rounded-lg px-2.5 py-1.5 text-xs text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800"
|
||||
>
|
||||
<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 dark:border-gray-700 rounded-lg px-2.5 py-1.5 text-xs text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800"
|
||||
>
|
||||
<option value="">🏘 전체 구/군</option>
|
||||
{districts.map((d) => (
|
||||
<option key={d} value={d}>🏘 {d}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{/* Toggle buttons */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = !boundsFilterOn;
|
||||
setBoundsFilterOn(next);
|
||||
if (next) {
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }),
|
||||
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 }),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
} else {
|
||||
setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 });
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`px-2.5 py-1.5 text-xs border rounded-lg transition-colors ${
|
||||
boundsFilterOn
|
||||
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400"
|
||||
: "text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800"
|
||||
value={cityFilter}
|
||||
onChange={(e) => handleCityChange(e.target.value)}
|
||||
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
|
||||
cityFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{boundsFilterOn ? "📍 내위치 ON" : "📍 내위치"}
|
||||
<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 dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
|
||||
districtFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<option value="">구/군</option>
|
||||
{districts.map((d) => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{countryFilter && (
|
||||
<button onClick={() => { setCountryFilter(""); setCityFilter(""); setDistrictFilter(""); setRegionFlyTo(null); }} className="text-gray-400 hover:text-brand-500">
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = !boundsFilterOn;
|
||||
setBoundsFilterOn(next);
|
||||
if (next) {
|
||||
setCuisineFilter("");
|
||||
setPriceFilter("");
|
||||
setCountryFilter("");
|
||||
setCityFilter("");
|
||||
setDistrictFilter("");
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }),
|
||||
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 }),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
} else {
|
||||
setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 });
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`flex items-center gap-0.5 rounded-lg px-2 py-1 border transition-colors ${
|
||||
boundsFilterOn
|
||||
? "bg-brand-50 dark:bg-brand-900/30 border-brand-300 dark:border-brand-700 text-brand-600 dark:text-brand-400"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<Icon name="location_on" size={12} />
|
||||
<span>{boundsFilterOn ? "내위치 ON" : "내위치"}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -924,7 +1075,7 @@ export default function Home() {
|
||||
<div className="hidden md:flex flex-1 overflow-hidden">
|
||||
{viewMode === "map" ? (
|
||||
<>
|
||||
<aside className="w-80 bg-white dark:bg-gray-950 border-r dark:border-gray-800 overflow-y-auto shrink-0">
|
||||
<aside className="w-80 bg-surface border-r dark:border-gray-800 overflow-y-auto shrink-0">
|
||||
{sidebarContent}
|
||||
</aside>
|
||||
<main className="flex-1 relative">
|
||||
@@ -946,7 +1097,7 @@ export default function Home() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<aside className="flex-1 bg-white dark:bg-gray-950 overflow-y-auto">
|
||||
<aside className="flex-1 bg-surface overflow-y-auto">
|
||||
{sidebarContent}
|
||||
</aside>
|
||||
<main className="w-[40%] shrink-0 relative border-l dark:border-gray-800">
|
||||
@@ -973,36 +1124,31 @@ export default function Home() {
|
||||
<div className="md:hidden flex-1 flex flex-col overflow-hidden pb-14">
|
||||
{/* Tab content — takes all remaining space above fixed nav */}
|
||||
{mobileTab === "nearby" ? (
|
||||
/* 내주변: 지도 + 리스트 분할, 영역필터 ON */
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="h-[45%] relative shrink-0">
|
||||
<MapView
|
||||
restaurants={filteredRestaurants}
|
||||
selected={selected}
|
||||
onSelectRestaurant={handleSelectRestaurant}
|
||||
onBoundsChanged={handleBoundsChanged}
|
||||
flyTo={regionFlyTo}
|
||||
onMyLocation={handleMyLocation}
|
||||
activeChannel={channelFilter || undefined}
|
||||
/>
|
||||
<div className="absolute top-2 left-2 bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm rounded-lg px-3 py-1.5 shadow-sm z-10">
|
||||
<span className="text-xs font-medium text-orange-600 dark:text-orange-400">
|
||||
내 주변 {filteredRestaurants.length}개
|
||||
</span>
|
||||
/* 내주변: 지도만 전체 표시, 영역필터 ON */
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<MapView
|
||||
restaurants={filteredRestaurants}
|
||||
selected={selected}
|
||||
onSelectRestaurant={handleSelectRestaurant}
|
||||
onBoundsChanged={handleBoundsChanged}
|
||||
flyTo={regionFlyTo}
|
||||
onMyLocation={handleMyLocation}
|
||||
activeChannel={channelFilter || undefined}
|
||||
/>
|
||||
<div className="absolute top-2 left-2 bg-surface/90 backdrop-blur-sm rounded-lg px-3 py-1.5 shadow-sm z-10">
|
||||
<span className="text-xs font-medium text-brand-600 dark:text-brand-400">
|
||||
내 주변 {filteredRestaurants.length}개
|
||||
</span>
|
||||
</div>
|
||||
{visits && (
|
||||
<div className="absolute bottom-1 right-2 bg-white/60 backdrop-blur-sm text-gray-700 text-[10px] px-2.5 py-1 rounded-lg shadow-sm z-10">
|
||||
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
|
||||
</div>
|
||||
{visits && (
|
||||
<div className="absolute bottom-1 right-2 bg-white/60 backdrop-blur-sm text-gray-700 text-[10px] px-2.5 py-1 rounded-lg shadow-sm z-10">
|
||||
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{mobileListContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : mobileTab === "profile" ? (
|
||||
/* 내정보 */
|
||||
<div className="flex-1 overflow-y-auto bg-white dark:bg-gray-950">
|
||||
<div className="flex-1 overflow-y-auto bg-surface">
|
||||
{!user ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 px-6">
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">로그인하고 리뷰와 찜 목록을 관리하세요</p>
|
||||
@@ -1022,7 +1168,7 @@ export default function Home() {
|
||||
{user.avatar_url ? (
|
||||
<img src={user.avatar_url} alt="" className="w-12 h-12 rounded-full border border-gray-200" />
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-orange-100 text-orange-700 flex items-center justify-center text-lg font-semibold border border-orange-200">
|
||||
<div className="w-12 h-12 rounded-full bg-brand-100 text-brand-700 flex items-center justify-center text-lg font-semibold border border-brand-200">
|
||||
{(user.nickname || user.email || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
@@ -1062,7 +1208,7 @@ export default function Home() {
|
||||
</div>
|
||||
) : (
|
||||
/* 홈 / 식당 목록 / 찜: 리스트 표시 */
|
||||
<div className="flex-1 overflow-y-auto bg-white dark:bg-gray-950">
|
||||
<div className="flex-1 overflow-y-auto bg-surface">
|
||||
{mobileTab === "favorites" && !user ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 px-6">
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">로그인하고 찜 목록을 확인하세요</p>
|
||||
@@ -1090,35 +1236,25 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
{/* ── Mobile Bottom Nav (fixed) ── */}
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-40 border-t dark:border-gray-800 bg-white dark:bg-gray-950 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">
|
||||
{([
|
||||
{ key: "home", label: "홈", icon: (
|
||||
<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: "식당 목록", icon: (
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M3 4h18v2H3zm0 7h18v2H3zm0 7h18v2H3z"/></svg>
|
||||
)},
|
||||
{ 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) => (
|
||||
{ key: "home", label: "홈", iconName: "home" },
|
||||
{ key: "list", label: "식당 목록", iconName: "restaurant_menu" },
|
||||
{ key: "nearby", label: "내주변", iconName: "near_me" },
|
||||
{ key: "favorites", label: "찜", iconName: "favorite" },
|
||||
{ key: "profile", label: "내정보", iconName: "person" },
|
||||
] as { key: "home" | "list" | "nearby" | "favorites" | "profile"; label: string; iconName: string }[]).map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => handleMobileTab(tab.key)}
|
||||
className={`flex-1 flex flex-col items-center justify-center gap-0.5 py-2 transition-colors ${
|
||||
mobileTab === tab.key
|
||||
? "text-orange-600 dark:text-orange-400"
|
||||
: "text-gray-400 dark:text-gray-500"
|
||||
? "text-brand-600 dark:text-brand-400"
|
||||
: "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>
|
||||
</button>
|
||||
))}
|
||||
@@ -1126,15 +1262,15 @@ export default function Home() {
|
||||
</nav>
|
||||
|
||||
{/* Desktop Footer */}
|
||||
<footer className="hidden md:flex shrink-0 border-t dark:border-gray-800 bg-white/60 dark:bg-gray-900/60 backdrop-blur-sm py-2.5 items-center justify-center gap-2 text-[11px] text-gray-400 dark:text-gray-500 group">
|
||||
<footer className="hidden md:flex shrink-0 border-t dark:border-gray-800 bg-surface/60 backdrop-blur-sm py-2.5 items-center justify-center gap-2 text-[11px] text-gray-500 dark:text-gray-500 group">
|
||||
<div className="relative">
|
||||
<img
|
||||
src="/icon.jpg"
|
||||
alt="SDJ Labs"
|
||||
className="w-6 h-6 rounded-full border-2 border-orange-200 shadow-sm group-hover:scale-110 group-hover:rotate-12 transition-all duration-300"
|
||||
className="w-6 h-6 rounded-full border-2 border-brand-200 shadow-sm group-hover:scale-110 group-hover:rotate-12 transition-all duration-300"
|
||||
/>
|
||||
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-orange-300 rounded-full animate-ping opacity-75" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-orange-400 rounded-full" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-brand-300 rounded-full animate-ping opacity-75" />
|
||||
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-brand-400 rounded-full" />
|
||||
</div>
|
||||
<span className="font-medium tracking-wide group-hover:text-gray-600 transition-colors">
|
||||
SDJ Labs Co., Ltd.
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function BottomSheet({ open, onClose, children }: BottomSheetProp
|
||||
{/* Sheet */}
|
||||
<div
|
||||
ref={sheetRef}
|
||||
className="fixed bottom-0 left-0 right-0 z-50 md:hidden flex flex-col bg-white/85 dark:bg-gray-900/90 backdrop-blur-xl rounded-t-2xl shadow-2xl"
|
||||
className="fixed bottom-0 left-0 right-0 z-50 md:hidden flex flex-col bg-surface/85 backdrop-blur-xl rounded-t-2xl shadow-2xl"
|
||||
style={{
|
||||
height: `${height * 100}vh`,
|
||||
transition: dragging ? "none" : "height 0.3s cubic-bezier(0.2, 0, 0, 1)",
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export default function LoginMenu({ onGoogleSuccess }: LoginMenuProps) {
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="px-3 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-orange-600 dark:hover:text-orange-400 border border-gray-300 dark:border-gray-600 hover:border-orange-400 dark:hover:border-orange-500 rounded-lg transition-colors"
|
||||
className="px-3 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-brand-600 dark:hover:text-brand-400 border border-gray-300 dark:border-gray-600 hover:border-brand-400 dark:hover:border-brand-500 rounded-lg transition-colors"
|
||||
>
|
||||
로그인
|
||||
</button>
|
||||
@@ -26,7 +26,7 @@ export default function LoginMenu({ onGoogleSuccess }: LoginMenuProps) {
|
||||
style={{ zIndex: 99999 }}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setOpen(false); }}
|
||||
>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl p-6 mx-4 w-full max-w-xs space-y-4">
|
||||
<div className="bg-surface rounded-2xl shadow-2xl p-6 mx-4 w-full max-w-xs space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold dark:text-gray-100">로그인</h3>
|
||||
<button
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@vis.gl/react-google-maps";
|
||||
import type { Restaurant } from "@/lib/api";
|
||||
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||
import Icon from "@/components/Icon";
|
||||
|
||||
const SEOUL_CENTER = { lat: 37.5665, lng: 126.978 };
|
||||
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",
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
</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 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" && (
|
||||
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold">폐업</span>
|
||||
)}
|
||||
@@ -231,16 +232,14 @@ export default function MapView({ restaurants, selected, onSelectRestaurant, onB
|
||||
{onMyLocation && (
|
||||
<button
|
||||
onClick={onMyLocation}
|
||||
className="absolute top-2 right-2 w-9 h-9 bg-white dark:bg-gray-900 rounded-lg shadow-md flex items-center justify-center text-gray-600 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-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="내 위치"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current">
|
||||
<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>
|
||||
<Icon name="my_location" size={20} />
|
||||
</button>
|
||||
)}
|
||||
{channelNames.length > 0 && (
|
||||
<div className="absolute bottom-2 left-2 bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm rounded-lg shadow px-2.5 py-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] z-10">
|
||||
<div className="absolute bottom-2 left-2 bg-surface/90 backdrop-blur-sm rounded-lg shadow px-2.5 py-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] z-10">
|
||||
{channelNames.map((ch) => (
|
||||
<div key={ch} className="flex items-center gap-1">
|
||||
<span
|
||||
|
||||
@@ -5,6 +5,7 @@ import { api, getToken } from "@/lib/api";
|
||||
import type { Restaurant, VideoLink } from "@/lib/api";
|
||||
import ReviewSection from "@/components/ReviewSection";
|
||||
import { RestaurantDetailSkeleton } from "@/components/Skeleton";
|
||||
import Icon from "@/components/Icon";
|
||||
|
||||
interface RestaurantDetailProps {
|
||||
restaurant: Restaurant;
|
||||
@@ -60,7 +61,7 @@ export default function RestaurantDetail({
|
||||
}`}
|
||||
title={favorited ? "찜 해제" : "찜하기"}
|
||||
>
|
||||
{favorited ? "♥" : "♡"}
|
||||
<Icon name="favorite" size={20} filled={favorited} />
|
||||
</button>
|
||||
)}
|
||||
{restaurant.business_status === "CLOSED_PERMANENTLY" && (
|
||||
@@ -78,7 +79,7 @@ export default function RestaurantDetail({
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none"
|
||||
>
|
||||
x
|
||||
<Icon name="close" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -117,7 +118,7 @@ export default function RestaurantDetail({
|
||||
{restaurant.phone && (
|
||||
<p>
|
||||
<span className="text-gray-500 dark:text-gray-400">전화:</span>{" "}
|
||||
<a href={`tel:${restaurant.phone}`} className="text-orange-600 dark:text-orange-400 hover:underline">
|
||||
<a href={`tel:${restaurant.phone}`} className="text-brand-600 dark:text-brand-400 hover:underline">
|
||||
{restaurant.phone}
|
||||
</a>
|
||||
</p>
|
||||
@@ -125,21 +126,23 @@ export default function RestaurantDetail({
|
||||
{restaurant.google_place_id && (
|
||||
<p className="flex gap-3">
|
||||
<a
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(restaurant.name)}`}
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(restaurant.name + (restaurant.address ? " " + restaurant.address : restaurant.region ? " " + restaurant.region.replace(/\|/g, " ") : ""))}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-orange-600 dark:text-orange-400 hover:underline text-xs"
|
||||
className="text-brand-600 dark:text-brand-400 hover:underline text-xs"
|
||||
>
|
||||
Google Maps에서 보기
|
||||
</a>
|
||||
<a
|
||||
href={`https://map.naver.com/v5/search/${encodeURIComponent(restaurant.name)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-green-600 dark:text-green-400 hover:underline text-xs"
|
||||
>
|
||||
네이버 지도에서 보기
|
||||
</a>
|
||||
{(!restaurant.region || restaurant.region.split("|")[0] === "한국") && (
|
||||
<a
|
||||
href={`https://map.naver.com/v5/search/${encodeURIComponent(restaurant.name)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-green-600 dark:text-green-400 hover:underline text-xs"
|
||||
>
|
||||
네이버 지도에서 보기
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -195,7 +198,7 @@ export default function RestaurantDetail({
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{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="text-[9px]">▶</span>
|
||||
<Icon name="play_circle" size={11} filled className="text-red-400" />
|
||||
{v.channel_name}
|
||||
</span>
|
||||
)}
|
||||
@@ -211,9 +214,7 @@ export default function RestaurantDetail({
|
||||
rel="noopener noreferrer"
|
||||
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">
|
||||
<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>
|
||||
<Icon name="play_circle" size={16} filled className="flex-shrink-0" />
|
||||
{v.title}
|
||||
</a>
|
||||
{v.foods_mentioned.length > 0 && (
|
||||
@@ -221,7 +222,7 @@ export default function RestaurantDetail({
|
||||
{v.foods_mentioned.map((f, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-2 py-0.5 bg-orange-50 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 rounded text-xs"
|
||||
className="px-2 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400 rounded text-xs"
|
||||
>
|
||||
{f}
|
||||
</span>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { Restaurant } from "@/lib/api";
|
||||
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||
import Icon from "@/components/Icon";
|
||||
import { RestaurantListSkeleton } from "@/components/Skeleton";
|
||||
|
||||
interface RestaurantListProps {
|
||||
@@ -39,13 +40,13 @@ export default function RestaurantList({
|
||||
onClick={() => onSelect(r)}
|
||||
className={`w-full text-left p-3 rounded-xl shadow-sm border transition-all hover:shadow-md hover:-translate-y-0.5 ${
|
||||
selectedId === r.id
|
||||
? "bg-orange-50 dark:bg-orange-900/20 border-orange-300 dark:border-orange-700 shadow-orange-100 dark:shadow-orange-900/10"
|
||||
: "bg-white dark:bg-gray-900 border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
? "bg-brand-50 dark:bg-brand-900/20 border-brand-300 dark:border-brand-700 shadow-brand-100 dark:shadow-brand-900/10"
|
||||
: "bg-surface border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="font-semibold text-sm dark:text-gray-100">
|
||||
<span className="mr-1">{getCuisineIcon(r.cuisine_type)}</span>
|
||||
<h4 className="font-semibold text-sm text-gray-900 dark:text-gray-100">
|
||||
<Icon name={getCuisineIcon(r.cuisine_type)} size={16} className="mr-0.5 text-brand-600" />
|
||||
{r.name}
|
||||
</h4>
|
||||
{r.rating && (
|
||||
@@ -56,27 +57,27 @@ export default function RestaurantList({
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-2 gap-y-0.5 mt-1.5 text-xs">
|
||||
{r.cuisine_type && (
|
||||
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-600 dark:text-gray-400">{r.cuisine_type}</span>
|
||||
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400">{r.cuisine_type}</span>
|
||||
)}
|
||||
{r.price_range && (
|
||||
<span className="px-1.5 py-0.5 bg-gray-50 dark:bg-gray-800 rounded text-gray-600 dark:text-gray-400">{r.price_range}</span>
|
||||
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400">{r.price_range}</span>
|
||||
)}
|
||||
</div>
|
||||
{r.region && (
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500 truncate">{r.region}</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-500 truncate">{r.region}</p>
|
||||
)}
|
||||
{r.foods_mentioned && r.foods_mentioned.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{r.foods_mentioned.slice(0, 5).map((f, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-1.5 py-0.5 bg-orange-50 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 rounded text-[10px]"
|
||||
className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400 rounded text-[10px]"
|
||||
>
|
||||
{f}
|
||||
</span>
|
||||
))}
|
||||
{r.foods_mentioned.length > 5 && (
|
||||
<span className="text-[10px] text-gray-400">+{r.foods_mentioned.length - 5}</span>
|
||||
<span className="text-[10px] text-gray-500">+{r.foods_mentioned.length - 5}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -85,8 +86,9 @@ export default function RestaurantList({
|
||||
{r.channels.map((ch) => (
|
||||
<span
|
||||
key={ch}
|
||||
className="px-1.5 py-0.5 bg-orange-50 dark:bg-orange-900/30 text-orange-600 dark:text-orange-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"
|
||||
>
|
||||
<Icon name="play_circle" size={11} filled className="shrink-0 text-red-400" />
|
||||
{ch}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -124,7 +124,7 @@ function ReviewForm({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-3 py-1 bg-orange-500 dark:bg-orange-600 text-white text-sm rounded hover:bg-orange-600 dark:hover:bg-orange-500 disabled:opacity-50"
|
||||
className="px-3 py-1 bg-brand-500 dark:bg-brand-600 text-white text-sm rounded hover:bg-brand-600 dark:hover:bg-brand-500 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "저장 중..." : submitLabel}
|
||||
</button>
|
||||
@@ -225,7 +225,7 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
|
||||
{user && !myReview && !showForm && (
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="mb-3 px-3 py-1 bg-orange-500 dark:bg-orange-600 text-white text-sm rounded hover:bg-orange-600 dark:hover:bg-orange-500"
|
||||
className="mb-3 px-3 py-1 bg-brand-500 dark:bg-brand-600 text-white text-sm rounded hover:bg-brand-600 dark:hover:bg-brand-500"
|
||||
>
|
||||
리뷰 작성
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Icon from "@/components/Icon";
|
||||
|
||||
interface SearchBarProps {
|
||||
onSearch: (query: string, mode: "keyword" | "semantic" | "hybrid") => void;
|
||||
@@ -9,40 +10,31 @@ interface SearchBarProps {
|
||||
|
||||
export default function SearchBar({ onSearch, isLoading }: SearchBarProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [mode, setMode] = useState<"keyword" | "semantic" | "hybrid">("hybrid");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (query.trim()) {
|
||||
onSearch(query.trim(), mode);
|
||||
onSearch(query.trim(), "hybrid");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex gap-1.5 items-center">
|
||||
<form onSubmit={handleSubmit} className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
|
||||
<Icon name="search" size={16} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="식당, 지역, 음식..."
|
||||
className="flex-1 min-w-0 px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-400 text-sm bg-white dark:bg-gray-800 dark:text-gray-200 dark:placeholder-gray-500"
|
||||
placeholder="식당, 지역, 음식 검색..."
|
||||
className="w-full pl-9 pr-3 py-2 bg-gray-100 dark:bg-gray-800 border border-transparent focus:border-brand-400 focus:bg-surface rounded-xl text-sm outline-none transition-all dark:text-gray-200 dark:placeholder-gray-500"
|
||||
/>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as typeof mode)}
|
||||
className="shrink-0 px-2 py-2 border border-gray-300 dark:border-gray-700 rounded-lg text-sm bg-white dark:bg-gray-800 dark:text-gray-300"
|
||||
>
|
||||
<option value="hybrid">통합</option>
|
||||
<option value="keyword">키워드</option>
|
||||
<option value="semantic">유사</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !query.trim()}
|
||||
className="shrink-0 px-3 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 disabled:opacity-50 text-sm"
|
||||
>
|
||||
{isLoading ? "..." : "검색"}
|
||||
</button>
|
||||
{isLoading && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="w-4 h-4 border-2 border-brand-400 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
BIN
frontend/src/fonts/PretendardVariable.woff2
Normal file
@@ -72,6 +72,7 @@ export interface Channel {
|
||||
title_filter: string | null;
|
||||
description: string | null;
|
||||
tags: string | null;
|
||||
sort_order: number | null;
|
||||
video_count: number;
|
||||
last_scanned_at: string | null;
|
||||
}
|
||||
@@ -352,7 +353,7 @@ export const api = {
|
||||
);
|
||||
},
|
||||
|
||||
updateChannel(id: string, data: { description?: string; tags?: string }) {
|
||||
updateChannel(id: string, data: { description?: string; tags?: string; sort_order?: number }) {
|
||||
return fetchApi<{ ok: boolean }>(`/api/channels/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
/**
|
||||
* Cuisine type → icon mapping.
|
||||
* Cuisine type → Material Symbols icon name mapping.
|
||||
* Works with "대분류|소분류" format (e.g. "한식|국밥/해장국").
|
||||
*/
|
||||
|
||||
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
|
||||
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: "🍜" },
|
||||
{ keyword: "회/횟집", icon: "set_meal" },
|
||||
{ keyword: "해산물", icon: "set_meal" },
|
||||
{ keyword: "삼겹살/돼지구이", icon: "kebab_dining" },
|
||||
{ keyword: "소고기/한우구이", icon: "kebab_dining" },
|
||||
{ keyword: "곱창/막창", icon: "kebab_dining" },
|
||||
{ keyword: "닭/오리구이", icon: "takeout_dining" },
|
||||
{ keyword: "스테이크", icon: "kebab_dining" },
|
||||
{ keyword: "햄버거", icon: "lunch_dining" },
|
||||
{ keyword: "피자", icon: "local_pizza" },
|
||||
{ keyword: "카페/디저트", icon: "coffee" },
|
||||
{ keyword: "베이커리", icon: "bakery_dining" },
|
||||
{ keyword: "치킨", icon: "takeout_dining" },
|
||||
{ keyword: "주점/포차", icon: "local_bar" },
|
||||
{ keyword: "이자카야", icon: "sake" },
|
||||
{ keyword: "라멘", icon: "ramen_dining" },
|
||||
{ keyword: "국밥/해장국", icon: "soup_kitchen" },
|
||||
{ keyword: "분식", icon: "ramen_dining" },
|
||||
];
|
||||
|
||||
const DEFAULT_ICON = "🍴";
|
||||
const DEFAULT_ICON = "flatware";
|
||||
|
||||
export function getCuisineIcon(cuisineType: string | null | undefined): string {
|
||||
if (!cuisineType) return DEFAULT_ICON;
|
||||
|
||||