fix(frontend): YouTube 수정 페이지 API 대응 및 사전 페이지 중복 요청 수정

- YouTubeEditForm: 변경된 API 응답 구조에 맞게 수정
  - schedule.youtube?.videoId → schedule.videoId
  - schedule.youtube?.channelName → schedule.channelName
  - schedule.date/time → schedule.datetime 파싱
- AdminScheduleDict: useEffect → useQuery 변경으로 중복 요청 방지

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-21 13:52:46 +09:00
parent 7593004bd6
commit 46469fd324
2 changed files with 45 additions and 38 deletions

View file

@ -1,6 +1,7 @@
import { useState, useEffect, useMemo, useRef } from 'react'; import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Home, ChevronRight, Book, Plus, Trash2, Search, ChevronDown } from 'lucide-react'; import { Home, ChevronRight, Book, Plus, Trash2, Search, ChevronDown } from 'lucide-react';
import Toast from '../../../components/Toast'; import Toast from '../../../components/Toast';
import AdminLayout from '../../../components/admin/AdminLayout'; import AdminLayout from '../../../components/admin/AdminLayout';
@ -171,8 +172,8 @@ function WordItem({ id, word, pos, index, onUpdate, onDelete }) {
function AdminScheduleDict() { function AdminScheduleDict() {
const { user, isAuthenticated } = useAdminAuth(); const { user, isAuthenticated } = useAdminAuth();
const { toast, setToast } = useToast(); const { toast, setToast } = useToast();
const queryClient = useQueryClient();
const [entries, setEntries] = useState([]); // [{word, pos, isComment, id}] const [entries, setEntries] = useState([]); // [{word, pos, isComment, id}]
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [filterPos, setFilterPos] = useState('all'); const [filterPos, setFilterPos] = useState('all');
@ -239,55 +240,59 @@ function AdminScheduleDict() {
return stats; return stats;
}, [entries]); }, [entries]);
useEffect(() => {
if (isAuthenticated) {
fetchDict();
}
}, [isAuthenticated]);
// ID // ID
const generateId = () => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const generateId = useCallback(() => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, []);
// //
const parseDict = (content) => { const parseDict = useCallback((content) => {
const lines = content.split('\n'); const lines = content.split('\n');
return lines.map(line => { return lines.map(line => {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) { if (!trimmed || trimmed.startsWith('#')) {
return { isComment: true, raw: line, id: generateId() }; return { isComment: true, raw: line, id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}` };
} }
const parts = trimmed.split('\t'); const parts = trimmed.split('\t');
return { return {
word: parts[0] || '', word: parts[0] || '',
pos: parts[1] || 'NNP', pos: parts[1] || 'NNP',
isComment: false, isComment: false,
id: generateId(), id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
}; };
}).filter(e => e.isComment || e.word); // }).filter(e => e.isComment || e.word); //
}; }, []);
// //
const serializeDict = (entries) => { const serializeDict = useCallback((entries) => {
return entries.map(e => { return entries.map(e => {
if (e.isComment) return e.raw; if (e.isComment) return e.raw;
return `${e.word}\t${e.pos}`; return `${e.word}\t${e.pos}`;
}).join('\n'); }).join('\n');
}; }, []);
// // (useQuery)
const fetchDict = async () => { const { data: dictContent, isLoading: loading, isError } = useQuery({
setLoading(true); queryKey: ['admin', 'dict'],
try { queryFn: async () => {
const data = await suggestionsApi.getDict(); const data = await suggestionsApi.getDict();
const parsed = parseDict(data.content || ''); return data.content || '';
},
enabled: isAuthenticated,
});
//
useEffect(() => {
if (dictContent !== undefined) {
const parsed = parseDict(dictContent);
setEntries(parsed); setEntries(parsed);
} catch (error) {
console.error('사전 조회 오류:', error);
setToast({ type: 'error', message: '사전을 불러올 수 없습니다.' });
} finally {
setLoading(false);
} }
}; }, [dictContent, parseDict]);
//
useEffect(() => {
if (isError) {
setToast({ type: 'error', message: '사전을 불러올 수 없습니다.' });
}
}, [isError, setToast]);
// (entries ) // (entries )
const saveDict = async (newEntries) => { const saveDict = async (newEntries) => {

View file

@ -92,7 +92,7 @@ function YouTubeEditForm() {
return; return;
} }
setSelectedMembers(schedule.members?.map((m) => m.id) || []); setSelectedMembers(schedule.members?.map((m) => m.id) || []);
setVideoType(schedule.youtube?.videoType || "video"); setVideoType(schedule.videoType || "video");
setIsInitialized(true); setIsInitialized(true);
} }
}, [schedule, isInitialized, navigate, setToast]); }, [schedule, isInitialized, navigate, setToast]);
@ -178,12 +178,14 @@ function YouTubeEditForm() {
} }
const videoUrl = videoType === "shorts" const videoUrl = videoType === "shorts"
? `https://www.youtube.com/shorts/${schedule.youtube?.videoId}` ? `https://www.youtube.com/shorts/${schedule.videoId}`
: `https://www.youtube.com/watch?v=${schedule.youtube?.videoId}`; : `https://www.youtube.com/watch?v=${schedule.videoId}`;
// // (datetime )
const formatDate = (dateStr, timeStr) => { const formatDatetime = (datetime) => {
if (!dateStr) return ""; if (!datetime) return "";
// datetime: "2025-01-20 14:00" "2025-01-20"
const [dateStr, timeStr] = datetime.split(" ");
const date = new Date(dateStr); const date = new Date(dateStr);
const year = date.getFullYear(); const year = date.getFullYear();
const month = date.getMonth() + 1; const month = date.getMonth() + 1;
@ -239,7 +241,7 @@ function YouTubeEditForm() {
<div className="bg-black rounded-xl overflow-hidden"> <div className="bg-black rounded-xl overflow-hidden">
<div className="relative aspect-[9/16]"> <div className="relative aspect-[9/16]">
<iframe <iframe
src={`https://www.youtube.com/embed/${schedule.youtube?.videoId}`} src={`https://www.youtube.com/embed/${schedule.videoId}`}
title={schedule.title} title={schedule.title}
className="absolute inset-0 w-full h-full" className="absolute inset-0 w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
@ -264,11 +266,11 @@ function YouTubeEditForm() {
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 mb-3"> <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 mb-3">
<span> <span>
<span className="text-gray-400">채널:</span>{" "} <span className="text-gray-400">채널:</span>{" "}
{schedule.youtube?.channelName} {schedule.channelName}
</span> </span>
<span> <span>
<span className="text-gray-400">업로드:</span>{" "} <span className="text-gray-400">업로드:</span>{" "}
{formatDate(schedule.date, schedule.time)} {formatDatetime(schedule.datetime)}
</span> </span>
</div> </div>
@ -379,7 +381,7 @@ function YouTubeEditForm() {
<div className="w-full bg-black rounded-xl overflow-hidden mb-6"> <div className="w-full bg-black rounded-xl overflow-hidden mb-6">
<div className="relative aspect-video"> <div className="relative aspect-video">
<iframe <iframe
src={`https://www.youtube.com/embed/${schedule.youtube?.videoId}`} src={`https://www.youtube.com/embed/${schedule.videoId}`}
title={schedule.title} title={schedule.title}
className="absolute inset-0 w-full h-full" className="absolute inset-0 w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
@ -396,11 +398,11 @@ function YouTubeEditForm() {
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-gray-600"> <div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-gray-600">
<span> <span>
<span className="text-gray-400">채널:</span>{" "} <span className="text-gray-400">채널:</span>{" "}
{schedule.youtube?.channelName} {schedule.channelName}
</span> </span>
<span> <span>
<span className="text-gray-400">업로드:</span>{" "} <span className="text-gray-400">업로드:</span>{" "}
{formatDate(schedule.date, schedule.time)} {formatDatetime(schedule.datetime)}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-gray-400">유형:</span> <span className="text-gray-400">유형:</span>