refactor: 생일 페이지 라우트를 /schedule/:id 형식으로 변경
- /birthday/:memberName/:year → /schedule/birthday-{year}-{nameEn}
- ScheduleDetail에서 특수 ID(birthday, debut, anniversary) 감지
- Birthday 컴포넌트가 props로 year, nameEn 받도록 변경
- 멤버 API가 영문명으로도 조회 가능하도록 수정
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e1ee0b47a0
commit
f97c925fba
9 changed files with 100 additions and 41 deletions
|
|
@ -62,8 +62,9 @@ export async function invalidateMemberCache(redis) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이름으로 멤버 조회 (별명 포함)
|
* 이름으로 멤버 조회 (별명 포함)
|
||||||
|
* 한글명(name) 또는 영문명(name_en) 모두 검색 가능
|
||||||
* @param {object} db - 데이터베이스 연결
|
* @param {object} db - 데이터베이스 연결
|
||||||
* @param {string} name - 멤버 이름
|
* @param {string} name - 멤버 이름 (한글 또는 영문)
|
||||||
* @returns {object|null} 멤버 정보 또는 null
|
* @returns {object|null} 멤버 정보 또는 null
|
||||||
*/
|
*/
|
||||||
export async function getMemberByName(db, name) {
|
export async function getMemberByName(db, name) {
|
||||||
|
|
@ -75,8 +76,8 @@ export async function getMemberByName(db, name) {
|
||||||
i.thumb_url as image_thumb
|
i.thumb_url as image_thumb
|
||||||
FROM members m
|
FROM members m
|
||||||
LEFT JOIN images i ON m.image_id = i.id
|
LEFT JOIN images i ON m.image_id = i.id
|
||||||
WHERE m.name = ?
|
WHERE m.name = ? OR LOWER(m.name_en) = LOWER(?)
|
||||||
`, [name]);
|
`, [name, name]);
|
||||||
|
|
||||||
if (members.length === 0) {
|
if (members.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
|
@ -6,25 +6,23 @@ import { fetchApi } from '@/api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mobile 생일 페이지
|
* Mobile 생일 페이지
|
||||||
|
* @param {object} props
|
||||||
|
* @param {string} props.year - 연도
|
||||||
|
* @param {string} props.nameEn - 멤버 영문 이름 (소문자)
|
||||||
*/
|
*/
|
||||||
function MobileBirthday() {
|
function MobileBirthday({ year, nameEn }) {
|
||||||
const { memberName, year } = useParams();
|
// 멤버 정보 조회 (영문 이름으로)
|
||||||
|
|
||||||
// URL 디코딩
|
|
||||||
const decodedMemberName = decodeURIComponent(memberName || '');
|
|
||||||
|
|
||||||
// 멤버 정보 조회
|
|
||||||
const {
|
const {
|
||||||
data: member,
|
data: member,
|
||||||
isLoading: memberLoading,
|
isLoading: memberLoading,
|
||||||
error,
|
error,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ['member', decodedMemberName],
|
queryKey: ['member', nameEn],
|
||||||
queryFn: () => fetchApi(`/members/${encodeURIComponent(decodedMemberName)}`),
|
queryFn: () => fetchApi(`/members/${encodeURIComponent(nameEn)}`),
|
||||||
enabled: !!decodedMemberName,
|
enabled: !!nameEn,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!decodedMemberName || error) {
|
if (!nameEn || error) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-6">
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|
@ -128,7 +126,7 @@ function MobileBirthday() {
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<div className="text-5xl mb-3">🎁</div>
|
<div className="text-5xl mb-3">🎁</div>
|
||||||
<p className="text-gray-500 text-sm">
|
<p className="text-gray-500 text-sm">
|
||||||
{year}년 {decodedMemberName} 생일카페 정보가 준비 중입니다
|
{year}년 {member?.name} 생일카페 정보가 준비 중입니다
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-400 text-xs mt-1">생일카페 정보가 등록되면 이곳에 표시됩니다</p>
|
<p className="text-gray-400 text-xs mt-1">생일카페 정보가 등록되면 이곳에 표시됩니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -777,11 +777,7 @@ function MobileSchedule() {
|
||||||
key={schedule.id}
|
key={schedule.id}
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
delay={index * 0.05}
|
delay={index * 0.05}
|
||||||
onClick={() => {
|
onClick={() => navigate(`/schedule/${schedule.id}`)}
|
||||||
const scheduleYear = new Date(schedule.date).getFullYear();
|
|
||||||
const memberName = schedule.member_names;
|
|
||||||
navigate(`/birthday/${encodeURIComponent(memberName)}/${scheduleYear}`);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,34 @@ import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight } from 'lucide-rea
|
||||||
import Linkify from 'react-linkify';
|
import Linkify from 'react-linkify';
|
||||||
import { getSchedule } from '@/api';
|
import { getSchedule } from '@/api';
|
||||||
import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTimeWithTime } from '@/utils';
|
import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTimeWithTime } from '@/utils';
|
||||||
|
import Birthday from './Birthday';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특수 일정 ID 파싱
|
||||||
|
* @param {string} id - 일정 ID
|
||||||
|
* @returns {object|null} { type, year, nameEn } 또는 null
|
||||||
|
*/
|
||||||
|
function parseSpecialId(id) {
|
||||||
|
// birthday-{year}-{nameEn} 형식
|
||||||
|
const birthdayMatch = id.match(/^birthday-(\d{4})-(.+)$/);
|
||||||
|
if (birthdayMatch) {
|
||||||
|
return { type: 'birthday', year: birthdayMatch[1], nameEn: birthdayMatch[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// debut-{year} 형식
|
||||||
|
const debutMatch = id.match(/^debut-(\d{4})$/);
|
||||||
|
if (debutMatch) {
|
||||||
|
return { type: 'debut', year: debutMatch[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// anniversary-{year} 형식
|
||||||
|
const anniversaryMatch = id.match(/^anniversary-(\d{4})$/);
|
||||||
|
if (anniversaryMatch) {
|
||||||
|
return { type: 'anniversary', year: anniversaryMatch[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 전체화면 시 자동 가로 회전 훅 (숏츠가 아닐 때만)
|
* 전체화면 시 자동 가로 회전 훅 (숏츠가 아닐 때만)
|
||||||
|
|
@ -393,6 +421,12 @@ function MobileDefaultSection({ schedule }) {
|
||||||
function MobileScheduleDetail() {
|
function MobileScheduleDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
|
||||||
|
// 특수 일정 ID 체크
|
||||||
|
const specialId = parseSpecialId(id);
|
||||||
|
if (specialId?.type === 'birthday') {
|
||||||
|
return <Birthday year={specialId.year} nameEn={specialId.nameEn} />;
|
||||||
|
}
|
||||||
|
|
||||||
// 모바일 레이아웃 활성화
|
// 모바일 레이아웃 활성화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.classList.add('mobile-layout');
|
document.documentElement.classList.add('mobile-layout');
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|
@ -6,25 +6,23 @@ import { fetchApi } from '@/api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PC 생일 페이지
|
* PC 생일 페이지
|
||||||
|
* @param {object} props
|
||||||
|
* @param {string} props.year - 연도
|
||||||
|
* @param {string} props.nameEn - 멤버 영문 이름 (소문자)
|
||||||
*/
|
*/
|
||||||
function PCBirthday() {
|
function PCBirthday({ year, nameEn }) {
|
||||||
const { memberName, year } = useParams();
|
// 멤버 정보 조회 (영문 이름으로)
|
||||||
|
|
||||||
// URL 디코딩
|
|
||||||
const decodedMemberName = decodeURIComponent(memberName || '');
|
|
||||||
|
|
||||||
// 멤버 정보 조회
|
|
||||||
const {
|
const {
|
||||||
data: member,
|
data: member,
|
||||||
isLoading: memberLoading,
|
isLoading: memberLoading,
|
||||||
error,
|
error,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ['member', decodedMemberName],
|
queryKey: ['member', nameEn],
|
||||||
queryFn: () => fetchApi(`/members/${encodeURIComponent(decodedMemberName)}`),
|
queryFn: () => fetchApi(`/members/${encodeURIComponent(nameEn)}`),
|
||||||
enabled: !!decodedMemberName,
|
enabled: !!nameEn,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!decodedMemberName || error) {
|
if (!nameEn || error) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-64px)] flex items-center justify-center">
|
<div className="min-h-[calc(100vh-64px)] flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|
@ -125,7 +123,7 @@ function PCBirthday() {
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="text-6xl mb-4">🎁</div>
|
<div className="text-6xl mb-4">🎁</div>
|
||||||
<p className="text-gray-500 text-lg">
|
<p className="text-gray-500 text-lg">
|
||||||
{year}년 {decodedMemberName} 생일카페 정보가 준비 중입니다
|
{year}년 {member?.name} 생일카페 정보가 준비 중입니다
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-400 text-sm mt-2">생일카페 정보가 등록되면 이곳에 표시됩니다</p>
|
<p className="text-gray-400 text-sm mt-2">생일카페 정보가 등록되면 이곳에 표시됩니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -262,15 +262,17 @@ function PCSchedule() {
|
||||||
|
|
||||||
// 일정 클릭 핸들러
|
// 일정 클릭 핸들러
|
||||||
const handleScheduleClick = (schedule) => {
|
const handleScheduleClick = (schedule) => {
|
||||||
if (schedule.is_birthday || String(schedule.id).startsWith('birthday-')) {
|
// 생일, 데뷔, 주년 등 특수 일정
|
||||||
const scheduleYear = new Date(schedule.date).getFullYear();
|
if (schedule.is_birthday || schedule.is_debut || schedule.is_anniversary) {
|
||||||
navigate(`/birthday/${encodeURIComponent(schedule.member_names)}/${scheduleYear}`);
|
navigate(`/schedule/${schedule.id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 유튜브(2), X(3), 콘서트(6) 카테고리
|
||||||
if ([2, 3, 6].includes(schedule.category_id)) {
|
if ([2, 3, 6].includes(schedule.category_id)) {
|
||||||
navigate(`/schedule/${schedule.id}`);
|
navigate(`/schedule/${schedule.id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 소스 URL이 있으면 외부 링크로
|
||||||
if (!schedule.description && schedule.source?.url) {
|
if (!schedule.description && schedule.source?.url) {
|
||||||
window.open(schedule.source.url, '_blank');
|
window.open(schedule.source.url, '_blank');
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,34 @@ import { getSchedule } from '@/api';
|
||||||
|
|
||||||
// 섹션 컴포넌트들
|
// 섹션 컴포넌트들
|
||||||
import { YoutubeSection, XSection, DefaultSection, decodeHtmlEntities } from './sections';
|
import { YoutubeSection, XSection, DefaultSection, decodeHtmlEntities } from './sections';
|
||||||
|
import Birthday from './Birthday';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특수 일정 ID 파싱
|
||||||
|
* @param {string} id - 일정 ID
|
||||||
|
* @returns {object|null} { type, year, nameEn } 또는 null
|
||||||
|
*/
|
||||||
|
function parseSpecialId(id) {
|
||||||
|
// birthday-{year}-{nameEn} 형식
|
||||||
|
const birthdayMatch = id.match(/^birthday-(\d{4})-(.+)$/);
|
||||||
|
if (birthdayMatch) {
|
||||||
|
return { type: 'birthday', year: birthdayMatch[1], nameEn: birthdayMatch[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// debut-{year} 형식
|
||||||
|
const debutMatch = id.match(/^debut-(\d{4})$/);
|
||||||
|
if (debutMatch) {
|
||||||
|
return { type: 'debut', year: debutMatch[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// anniversary-{year} 형식
|
||||||
|
const anniversaryMatch = id.match(/^anniversary-(\d{4})$/);
|
||||||
|
if (anniversaryMatch) {
|
||||||
|
return { type: 'anniversary', year: anniversaryMatch[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PC 일정 상세 페이지
|
* PC 일정 상세 페이지
|
||||||
|
|
@ -13,6 +41,12 @@ import { YoutubeSection, XSection, DefaultSection, decodeHtmlEntities } from './
|
||||||
function PCScheduleDetail() {
|
function PCScheduleDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
|
||||||
|
// 특수 일정 ID 체크
|
||||||
|
const specialId = parseSpecialId(id);
|
||||||
|
if (specialId?.type === 'birthday') {
|
||||||
|
return <Birthday year={specialId.year} nameEn={specialId.nameEn} />;
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: schedule,
|
data: schedule,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import Members from '@/pages/mobile/members/Members';
|
||||||
import MembersPreview from '@/pages/mobile/members/MembersPreview';
|
import MembersPreview from '@/pages/mobile/members/MembersPreview';
|
||||||
import Schedule from '@/pages/mobile/schedule/Schedule';
|
import Schedule from '@/pages/mobile/schedule/Schedule';
|
||||||
import ScheduleDetail from '@/pages/mobile/schedule/ScheduleDetail';
|
import ScheduleDetail from '@/pages/mobile/schedule/ScheduleDetail';
|
||||||
import Birthday from '@/pages/mobile/schedule/Birthday';
|
|
||||||
import Album from '@/pages/mobile/album/Album';
|
import Album from '@/pages/mobile/album/Album';
|
||||||
import AlbumDetail from '@/pages/mobile/album/AlbumDetail';
|
import AlbumDetail from '@/pages/mobile/album/AlbumDetail';
|
||||||
import TrackDetail from '@/pages/mobile/album/TrackDetail';
|
import TrackDetail from '@/pages/mobile/album/TrackDetail';
|
||||||
|
|
@ -55,7 +54,6 @@ export default function MobileRoutes() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/schedule/:id" element={<ScheduleDetail />} />
|
<Route path="/schedule/:id" element={<ScheduleDetail />} />
|
||||||
<Route path="/birthday/:memberName/:year" element={<Birthday />} />
|
|
||||||
<Route
|
<Route
|
||||||
path="/album"
|
path="/album"
|
||||||
element={
|
element={
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import Home from '@/pages/pc/public/home/Home';
|
||||||
import Members from '@/pages/pc/public/members/Members';
|
import Members from '@/pages/pc/public/members/Members';
|
||||||
import Schedule from '@/pages/pc/public/schedule/Schedule';
|
import Schedule from '@/pages/pc/public/schedule/Schedule';
|
||||||
import ScheduleDetail from '@/pages/pc/public/schedule/ScheduleDetail';
|
import ScheduleDetail from '@/pages/pc/public/schedule/ScheduleDetail';
|
||||||
import Birthday from '@/pages/pc/public/schedule/Birthday';
|
|
||||||
import Album from '@/pages/pc/public/album/Album';
|
import Album from '@/pages/pc/public/album/Album';
|
||||||
import AlbumDetail from '@/pages/pc/public/album/AlbumDetail';
|
import AlbumDetail from '@/pages/pc/public/album/AlbumDetail';
|
||||||
import TrackDetail from '@/pages/pc/public/album/TrackDetail';
|
import TrackDetail from '@/pages/pc/public/album/TrackDetail';
|
||||||
|
|
@ -30,7 +29,6 @@ export default function PublicRoutes() {
|
||||||
<Route path="/members" element={<Members />} />
|
<Route path="/members" element={<Members />} />
|
||||||
<Route path="/schedule" element={<Schedule />} />
|
<Route path="/schedule" element={<Schedule />} />
|
||||||
<Route path="/schedule/:id" element={<ScheduleDetail />} />
|
<Route path="/schedule/:id" element={<ScheduleDetail />} />
|
||||||
<Route path="/birthday/:memberName/:year" element={<Birthday />} />
|
|
||||||
<Route path="/album" element={<Album />} />
|
<Route path="/album" element={<Album />} />
|
||||||
<Route path="/album/:name" element={<AlbumDetail />} />
|
<Route path="/album/:name" element={<AlbumDetail />} />
|
||||||
<Route path="/album/:name/track/:trackTitle" element={<TrackDetail />} />
|
<Route path="/album/:name/track/:trackTitle" element={<TrackDetail />} />
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue