Fix login 401, admin permission, video links serialization, and admin UI styling
- Fix UserInfo boolean field naming (isAdmin → admin) for proper Jackson/MyBatis mapping - Configure Google OAuth audience with actual client ID to fix token verification - Parse CLOB fields and convert Oracle TIMESTAMP in restaurant video links API - Add explicit bg-white/text-gray-900 to admin page inputs, selects, and table headers - Add keyPrefix to RestaurantList to avoid duplicate React keys across desktop/mobile Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@ public class UserInfo {
|
|||||||
private String nickname;
|
private String nickname;
|
||||||
private String avatarUrl;
|
private String avatarUrl;
|
||||||
@JsonProperty("is_admin")
|
@JsonProperty("is_admin")
|
||||||
private boolean isAdmin;
|
private boolean admin;
|
||||||
private String provider;
|
private String provider;
|
||||||
private String providerId;
|
private String providerId;
|
||||||
private String createdAt;
|
private String createdAt;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.google.api.client.http.javanet.NetHttpTransport;
|
|||||||
import com.google.api.client.json.gson.GsonFactory;
|
import com.google.api.client.json.gson.GsonFactory;
|
||||||
import com.tasteby.domain.UserInfo;
|
import com.tasteby.domain.UserInfo;
|
||||||
import com.tasteby.security.JwtTokenProvider;
|
import com.tasteby.security.JwtTokenProvider;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
@@ -20,12 +21,13 @@ public class AuthService {
|
|||||||
private final JwtTokenProvider jwtProvider;
|
private final JwtTokenProvider jwtProvider;
|
||||||
private final GoogleIdTokenVerifier verifier;
|
private final GoogleIdTokenVerifier verifier;
|
||||||
|
|
||||||
public AuthService(UserService userService, JwtTokenProvider jwtProvider) {
|
public AuthService(UserService userService, JwtTokenProvider jwtProvider,
|
||||||
|
@Value("${app.google.client-id}") String googleClientId) {
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
this.jwtProvider = jwtProvider;
|
this.jwtProvider = jwtProvider;
|
||||||
this.verifier = new GoogleIdTokenVerifier.Builder(
|
this.verifier = new GoogleIdTokenVerifier.Builder(
|
||||||
new NetHttpTransport(), GsonFactory.getDefaultInstance())
|
new NetHttpTransport(), GsonFactory.getDefaultInstance())
|
||||||
.setAudience(Collections.emptyList()) // Accept any audience
|
.setAudience(Collections.singletonList(googleClientId))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,13 @@ public class RestaurantService {
|
|||||||
|
|
||||||
public List<Map<String, Object>> findVideoLinks(String restaurantId) {
|
public List<Map<String, Object>> findVideoLinks(String restaurantId) {
|
||||||
var rows = mapper.findVideoLinks(restaurantId);
|
var rows = mapper.findVideoLinks(restaurantId);
|
||||||
return rows.stream().map(JsonUtil::lowerKeys).toList();
|
return rows.stream().map(row -> {
|
||||||
|
var m = JsonUtil.lowerKeys(row);
|
||||||
|
m.put("foods_mentioned", JsonUtil.parseStringList(m.get("foods_mentioned")));
|
||||||
|
m.put("evaluation", JsonUtil.parseMap(m.get("evaluation")));
|
||||||
|
m.put("guests", JsonUtil.parseStringList(m.get("guests")));
|
||||||
|
return m;
|
||||||
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void update(String id, Map<String, Object> fields) {
|
public void update(String id, Map<String, Object> fields) {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ app:
|
|||||||
google:
|
google:
|
||||||
maps-api-key: ${GOOGLE_MAPS_API_KEY}
|
maps-api-key: ${GOOGLE_MAPS_API_KEY}
|
||||||
youtube-api-key: ${YOUTUBE_DATA_API_KEY}
|
youtube-api-key: ${YOUTUBE_DATA_API_KEY}
|
||||||
|
client-id: ${GOOGLE_CLIENT_ID:635551099330-2l003d3ernjmkqavd4f6s78r8r405iml.apps.googleusercontent.com}
|
||||||
|
|
||||||
cache:
|
cache:
|
||||||
ttl-seconds: 600
|
ttl-seconds: 600
|
||||||
|
|||||||
@@ -60,7 +60,8 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findVideoLinks" resultType="map">
|
<select id="findVideoLinks" resultType="map">
|
||||||
SELECT v.video_id, v.title, v.url, v.published_at,
|
SELECT v.video_id, v.title, v.url,
|
||||||
|
TO_CHAR(v.published_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS published_at,
|
||||||
vr.foods_mentioned, vr.evaluation, vr.guests,
|
vr.foods_mentioned, vr.evaluation, vr.guests,
|
||||||
c.channel_name, c.channel_id
|
c.channel_name, c.channel_id
|
||||||
FROM video_restaurants vr
|
FROM video_restaurants vr
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<result property="email" column="email"/>
|
<result property="email" column="email"/>
|
||||||
<result property="nickname" column="nickname"/>
|
<result property="nickname" column="nickname"/>
|
||||||
<result property="avatarUrl" column="avatar_url"/>
|
<result property="avatarUrl" column="avatar_url"/>
|
||||||
<result property="isAdmin" column="is_admin" javaType="boolean"/>
|
<result property="admin" column="is_admin" javaType="boolean"/>
|
||||||
<result property="provider" column="provider"/>
|
<result property="provider" column="provider"/>
|
||||||
<result property="createdAt" column="created_at"/>
|
<result property="createdAt" column="created_at"/>
|
||||||
<result property="favoriteCount" column="favorite_count"/>
|
<result property="favoriteCount" column="favorite_count"/>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50 text-gray-900">
|
||||||
<header className="bg-white border-b px-6 py-4">
|
<header className="bg-white border-b px-6 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -133,19 +133,19 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
placeholder="YouTube Channel ID"
|
placeholder="YouTube Channel ID"
|
||||||
value={newId}
|
value={newId}
|
||||||
onChange={(e) => setNewId(e.target.value)}
|
onChange={(e) => setNewId(e.target.value)}
|
||||||
className="border rounded px-3 py-2 flex-1 text-sm"
|
className="border rounded px-3 py-2 flex-1 text-sm bg-white text-gray-900"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
placeholder="채널 이름"
|
placeholder="채널 이름"
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
className="border rounded px-3 py-2 flex-1 text-sm"
|
className="border rounded px-3 py-2 flex-1 text-sm bg-white text-gray-900"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
placeholder="제목 필터 (선택)"
|
placeholder="제목 필터 (선택)"
|
||||||
value={newFilter}
|
value={newFilter}
|
||||||
onChange={(e) => setNewFilter(e.target.value)}
|
onChange={(e) => setNewFilter(e.target.value)}
|
||||||
className="border rounded px-3 py-2 w-40 text-sm"
|
className="border rounded px-3 py-2 w-40 text-sm bg-white text-gray-900"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
@@ -159,7 +159,7 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
|
|
||||||
<div className="bg-white rounded-lg shadow">
|
<div className="bg-white rounded-lg shadow">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50 border-b">
|
<thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-4 py-3">채널 이름</th>
|
<th className="text-left px-4 py-3">채널 이름</th>
|
||||||
<th className="text-left px-4 py-3">Channel ID</th>
|
<th className="text-left px-4 py-3">Channel ID</th>
|
||||||
@@ -661,7 +661,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<select
|
<select
|
||||||
value={channelFilter}
|
value={channelFilter}
|
||||||
onChange={(e) => { setChannelFilter(e.target.value); setPage(0); }}
|
onChange={(e) => { setChannelFilter(e.target.value); setPage(0); }}
|
||||||
className="border rounded px-3 py-2 text-sm"
|
className="border rounded px-3 py-2 text-sm bg-white text-gray-900"
|
||||||
>
|
>
|
||||||
<option value="">전체 채널</option>
|
<option value="">전체 채널</option>
|
||||||
{channels.map((ch) => (
|
{channels.map((ch) => (
|
||||||
@@ -671,7 +671,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
className="border rounded px-3 py-2 text-sm"
|
className="border rounded px-3 py-2 text-sm bg-white text-gray-900"
|
||||||
>
|
>
|
||||||
<option value="">전체 상태</option>
|
<option value="">전체 상태</option>
|
||||||
<option value="pending">대기중</option>
|
<option value="pending">대기중</option>
|
||||||
@@ -687,7 +687,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
value={titleSearch}
|
value={titleSearch}
|
||||||
onChange={(e) => { setTitleSearch(e.target.value); setPage(0); }}
|
onChange={(e) => { setTitleSearch(e.target.value); setPage(0); }}
|
||||||
onKeyDown={(e) => e.key === "Escape" && setTitleSearch("")}
|
onKeyDown={(e) => e.key === "Escape" && setTitleSearch("")}
|
||||||
className="border border-r-0 rounded-l px-3 py-2 text-sm w-48"
|
className="border border-r-0 rounded-l px-3 py-2 text-sm w-48 bg-white text-gray-900"
|
||||||
/>
|
/>
|
||||||
{titleSearch ? (
|
{titleSearch ? (
|
||||||
<button
|
<button
|
||||||
@@ -779,7 +779,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
|
|
||||||
<div className="bg-white rounded-lg shadow overflow-auto min-w-[800px]">
|
<div className="bg-white rounded-lg shadow overflow-auto min-w-[800px]">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50 border-b">
|
<thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 w-8">
|
<th className="px-4 py-3 w-8">
|
||||||
<input
|
<input
|
||||||
@@ -1187,34 +1187,34 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-gray-500">식당명 *</label>
|
<label className="text-[10px] text-gray-500">식당명 *</label>
|
||||||
<input value={manualForm.name} onChange={(e) => setManualForm(f => ({ ...f, name: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs" 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-white text-gray-900" placeholder="식당 이름" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-gray-500">주소</label>
|
<label className="text-[10px] text-gray-500">주소</label>
|
||||||
<input value={manualForm.address} onChange={(e) => setManualForm(f => ({ ...f, address: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs" 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-white text-gray-900" placeholder="주소 (없으면 지역)" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-gray-500">지역</label>
|
<label className="text-[10px] text-gray-500">지역</label>
|
||||||
<input value={manualForm.region} onChange={(e) => setManualForm(f => ({ ...f, region: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs" 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-white text-gray-900" placeholder="서울 강남" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-gray-500">음식 종류</label>
|
<label className="text-[10px] text-gray-500">음식 종류</label>
|
||||||
<input value={manualForm.cuisine_type} onChange={(e) => setManualForm(f => ({ ...f, cuisine_type: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs" 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-white text-gray-900" placeholder="한식, 일식..." />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-gray-500">메뉴</label>
|
<label className="text-[10px] text-gray-500">메뉴</label>
|
||||||
<input value={manualForm.foods_mentioned} onChange={(e) => setManualForm(f => ({ ...f, foods_mentioned: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs" 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-white text-gray-900" placeholder="메뉴1, 메뉴2" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-gray-500">게스트</label>
|
<label className="text-[10px] text-gray-500">게스트</label>
|
||||||
<input value={manualForm.guests} onChange={(e) => setManualForm(f => ({ ...f, guests: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs" 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-white text-gray-900" placeholder="게스트1, 게스트2" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] text-gray-500">평가/요약</label>
|
<label className="text-[10px] text-gray-500">평가/요약</label>
|
||||||
<textarea value={manualForm.evaluation} onChange={(e) => setManualForm(f => ({ ...f, evaluation: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs" 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-white text-gray-900" rows={2} placeholder="맛집 평가 내용" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -1257,7 +1257,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<textarea
|
<textarea
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
className="w-full border rounded p-2 text-xs font-mono mb-2 bg-gray-50"
|
className="w-full border rounded p-2 text-xs font-mono mb-2 bg-white text-gray-900"
|
||||||
rows={12}
|
rows={12}
|
||||||
placeholder="프롬프트 템플릿 ({title}, {transcript} 변수 사용)"
|
placeholder="프롬프트 템플릿 ({title}, {transcript} 변수 사용)"
|
||||||
/>
|
/>
|
||||||
@@ -1271,39 +1271,39 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500">이름</label>
|
<label className="text-xs text-gray-500">이름</label>
|
||||||
<input value={editRest.name} onChange={(e) => setEditRest({ ...editRest, name: e.target.value })} className="w-full border rounded px-2 py-1 text-sm" />
|
<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" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500">종류</label>
|
<label className="text-xs text-gray-500">종류</label>
|
||||||
<input value={editRest.cuisine_type} onChange={(e) => setEditRest({ ...editRest, cuisine_type: e.target.value })} className="w-full border rounded px-2 py-1 text-xs" />
|
<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" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500">가격대</label>
|
<label className="text-xs text-gray-500">가격대</label>
|
||||||
<input value={editRest.price_range} onChange={(e) => setEditRest({ ...editRest, price_range: e.target.value })} className="w-full border rounded px-2 py-1 text-xs" />
|
<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" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500">지역</label>
|
<label className="text-xs text-gray-500">지역</label>
|
||||||
<input value={editRest.region} onChange={(e) => setEditRest({ ...editRest, region: e.target.value })} className="w-full border rounded px-2 py-1 text-xs" />
|
<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" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500">주소</label>
|
<label className="text-xs text-gray-500">주소</label>
|
||||||
<input value={editRest.address} onChange={(e) => setEditRest({ ...editRest, address: e.target.value })} className="w-full border rounded px-2 py-1 text-xs" />
|
<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" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500">메뉴 (쉼표 구분)</label>
|
<label className="text-xs text-gray-500">메뉴 (쉼표 구분)</label>
|
||||||
<input value={editRest.foods_mentioned} onChange={(e) => setEditRest({ ...editRest, foods_mentioned: e.target.value })} className="w-full border rounded px-2 py-1 text-xs" 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-white text-gray-900" placeholder="메뉴1, 메뉴2, ..." />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500">평가/요약</label>
|
<label className="text-xs text-gray-500">평가/요약</label>
|
||||||
<textarea value={editRest.evaluation} onChange={(e) => setEditRest({ ...editRest, evaluation: e.target.value })} className="w-full border rounded px-2 py-1 text-xs" 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-white text-gray-900" rows={2} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-500">게스트 (쉼표 구분)</label>
|
<label className="text-xs text-gray-500">게스트 (쉼표 구분)</label>
|
||||||
<input value={editRest.guests} onChange={(e) => setEditRest({ ...editRest, guests: e.target.value })} className="w-full border rounded px-2 py-1 text-xs" />
|
<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" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -1442,7 +1442,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<select
|
<select
|
||||||
value={transcriptMode}
|
value={transcriptMode}
|
||||||
onChange={(e) => setTranscriptMode(e.target.value as "auto" | "manual" | "generated")}
|
onChange={(e) => setTranscriptMode(e.target.value as "auto" | "manual" | "generated")}
|
||||||
className="border rounded px-2 py-1 text-xs"
|
className="border rounded px-2 py-1 text-xs bg-white text-gray-900"
|
||||||
>
|
>
|
||||||
<option value="auto">자동 (수동→자동생성)</option>
|
<option value="auto">자동 (수동→자동생성)</option>
|
||||||
<option value="manual">수동 자막만</option>
|
<option value="manual">수동 자막만</option>
|
||||||
@@ -1598,7 +1598,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
value={nameSearch}
|
value={nameSearch}
|
||||||
onChange={(e) => { setNameSearch(e.target.value); setPage(0); }}
|
onChange={(e) => { setNameSearch(e.target.value); setPage(0); }}
|
||||||
onKeyDown={(e) => e.key === "Escape" && setNameSearch("")}
|
onKeyDown={(e) => e.key === "Escape" && setNameSearch("")}
|
||||||
className="border border-r-0 rounded-l px-3 py-2 text-sm w-48"
|
className="border border-r-0 rounded-l px-3 py-2 text-sm w-48 bg-white text-gray-900"
|
||||||
/>
|
/>
|
||||||
{nameSearch ? (
|
{nameSearch ? (
|
||||||
<button
|
<button
|
||||||
@@ -1624,7 +1624,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
|
|
||||||
<div className="bg-white rounded-lg shadow overflow-auto">
|
<div className="bg-white rounded-lg shadow overflow-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50 border-b">
|
<thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("name")}>이름{sortIcon("name")}</th>
|
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("name")}>이름{sortIcon("name")}</th>
|
||||||
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("region")}>지역{sortIcon("region")}</th>
|
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("region")}>지역{sortIcon("region")}</th>
|
||||||
@@ -1706,7 +1706,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<input
|
<input
|
||||||
value={editForm[key] || ""}
|
value={editForm[key] || ""}
|
||||||
onChange={(e) => setEditForm((f) => ({ ...f, [key]: e.target.value }))}
|
onChange={(e) => setEditForm((f) => ({ ...f, [key]: e.target.value }))}
|
||||||
className="w-full border rounded px-2 py-1.5 text-sm"
|
className="w-full border rounded px-2 py-1.5 text-sm bg-white text-gray-900"
|
||||||
disabled={!isAdmin}
|
disabled={!isAdmin}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1866,7 +1866,7 @@ function UsersPanel() {
|
|||||||
{/* Users Table */}
|
{/* Users Table */}
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50 text-gray-600">
|
<thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-4 py-2">사용자</th>
|
<th className="text-left px-4 py-2">사용자</th>
|
||||||
<th className="text-left px-4 py-2">이메일</th>
|
<th className="text-left px-4 py-2">이메일</th>
|
||||||
|
|||||||
@@ -392,6 +392,7 @@ export default function Home() {
|
|||||||
selectedId={selected?.id}
|
selectedId={selected?.id}
|
||||||
onSelect={handleSelectRestaurant}
|
onSelect={handleSelectRestaurant}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
keyPrefix="d-"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -415,6 +416,7 @@ export default function Home() {
|
|||||||
selectedId={selected?.id}
|
selectedId={selected?.id}
|
||||||
onSelect={handleSelectRestaurant}
|
onSelect={handleSelectRestaurant}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
keyPrefix="m-"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface RestaurantListProps {
|
|||||||
selectedId?: string;
|
selectedId?: string;
|
||||||
onSelect: (r: Restaurant) => void;
|
onSelect: (r: Restaurant) => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
keyPrefix?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RestaurantList({
|
export default function RestaurantList({
|
||||||
@@ -16,6 +17,7 @@ export default function RestaurantList({
|
|||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
loading,
|
loading,
|
||||||
|
keyPrefix = "",
|
||||||
}: RestaurantListProps) {
|
}: RestaurantListProps) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <RestaurantListSkeleton />;
|
return <RestaurantListSkeleton />;
|
||||||
@@ -33,7 +35,7 @@ export default function RestaurantList({
|
|||||||
<div className="p-3 space-y-2">
|
<div className="p-3 space-y-2">
|
||||||
{restaurants.map((r) => (
|
{restaurants.map((r) => (
|
||||||
<button
|
<button
|
||||||
key={r.id}
|
key={`${keyPrefix}${r.id}`}
|
||||||
onClick={() => onSelect(r)}
|
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 ${
|
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
|
selectedId === r.id
|
||||||
|
|||||||
Reference in New Issue
Block a user