diff --git a/src/apis/aquarium.ts b/src/apis/aquarium.ts index 5fda02a..da9162b 100644 --- a/src/apis/aquarium.ts +++ b/src/apis/aquarium.ts @@ -125,3 +125,52 @@ export async function getAquariumDetail(): Promise { }); } } + +/** + * 아쿠아리움 물고기 노출 설정을 벌크로 업데이트합니다. + * @param fishSettings 물고기별 노출 설정 배열 + */ +export async function updateAquariumFishVisibility( + fishSettings: Array<{ id: number; visible: boolean }>, +): Promise { + try { + await api.post("/aquatics/aquarium/fishes/visibility/", { + fish_settings: fishSettings, + }); + } catch (error) { + throwMapped(error, { + 400: "잘못된 요청입니다. 물고기 ID를 확인해주세요.", + 401: "로그인이 필요합니다.", + 500: "서버 오류로 물고기 노출 설정을 업데이트하지 못했습니다.", + }); + } +} + +/** + * 유저가 보유한 모든 물고기 목록을 반환합니다. + * @returns 사용자가 보유한 모든 물고기 목록 (visibility와 관계없이) + */ +export interface UserFish { + id: number; + species_name: string; + repository_full_name: string; + group_code: string; + maturity: number; + github_username: string; + commit_count: number; + is_visible_in_fishtank: boolean; + is_visible_in_aquarium: boolean; + aquarium: number | null; +} + +export async function getMyFishes(): Promise { + try { + const res = await api.get("/aquatics/my-fishes/"); + return res.data; + } catch (e) { + throwMapped(e, { + 401: "로그인이 필요합니다.", + 500: "서버 오류로 물고기 목록을 불러오지 못했습니다.", + }); + } +} diff --git a/src/apis/fishtank.ts b/src/apis/fishtank.ts index 994e1e7..b4c7f68 100644 --- a/src/apis/fishtank.ts +++ b/src/apis/fishtank.ts @@ -1,17 +1,6 @@ import { api } from "./axios"; import type { AxiosError } from "axios"; - -export interface FishtankBackground { - id: number; // OwnBackground의 id - background: { - id: number; - name: string; - code: string; - svg_template?: string; - }; - unlocked_at: string; - // background_image는 프론트엔드에서 로컬 assets를 사용하므로 제외 -} +import type { Fish } from "@/types/fish"; export interface RepositoryOwner { id: number; @@ -39,21 +28,6 @@ export interface Repository { my_commit_count: number; // 현재 로그인한 사용자의 해당 레포지토리 커밋 수 } -export interface ContributionFishSpecies { - id: number; - name: string; - maturity: number; - required_commits: number; - svg_template: string; - group_code: string; -} - -export interface ContributionFish { - id: number; - is_visible_in_fishtank: boolean; - species: ContributionFishSpecies; -} - export interface SelectableFish { id: number | null; // 할당되지 않은 물고기는 null username: string | null; // 할당되지 않은 물고기는 null @@ -67,18 +41,12 @@ export interface SelectableFish { svg_template?: string; // SVG 템플릿 코드 } -export interface Contributor { - id: number; - user: string; - commit_count: number; - fish: ContributionFish | null; -} - export interface FishtankDetail { id: number; - repository: string; - svg_path: string | null; - contributors: Contributor[]; + repository_full_name: string; + svg_url: string | null; + background_name: string; + fish_list: Fish[]; // types/fish.ts의 Fish 인터페이스 사용 } /** AxiosError 타입 가드 */ @@ -152,23 +120,6 @@ export async function getMyBackgrounds(): Promise { } } -/** - * 유저가 보유한 배경(OwnBackground)의 원본 Background 데이터를 반환합니다. - * @deprecated getMyBackgrounds()를 사용하세요 - * @returns 사용자가 보유한 피쉬탱크 배경 목록 - */ -export async function getFishtankBackgrounds(): Promise { - try { - const res = await api.get("/aquatics/fishtank/backgrounds/"); - return res.data; - } catch (e) { - throwMapped(e, { - 401: "로그인이 필요합니다.", - 500: "서버 오류로 배경 목록을 불러오지 못했습니다.", - }); - } -} - /** * repo ID를 기반으로 FishTank 내부 정보(기여자, 물고기)를 조회합니다. * @param repoId repo ID @@ -188,13 +139,13 @@ export async function getFishtankDetail(repoId: string): Promise } /** - * 사용자가 소유한 OwnBackground 중 하나를 fishtank 배경으로 적용합니다. + * 사용자가 소유한 배경을 fishtank 배경으로 적용합니다. * @param repoId 레포지토리 ID - * @param backgroundId 유저가 소유한 OwnBackground.background.id + * @param backgroundId Background.id (사용자가 소유한 배경의 background_id) */ export async function applyFishtankBackground(repoId: string, backgroundId: number): Promise { try { - await api.post(`/aquatics/fishtank/${repoId}/apply-background/`, { + await api.post(`/aquatics/fishtank/${repoId}/background/`, { background_id: backgroundId, }); } catch (e) { @@ -226,31 +177,3 @@ export async function getSelectableFish(repoId: string): Promise; -} - -/** - * 특정 Repository의 Fishtank 스프라이트 데이터를 조회합니다. - * @param repoId 레포지토리 ID - * @returns 배경 URL과 물고기 스프라이트 데이터 - */ -export async function getFishtankSprites(repoId: string): Promise { - try { - const res = await api.get(`/aquatics/fishtank/${repoId}/sprites/`); - return res.data; - } catch (e) { - throwMapped(e, { - 401: "로그인이 필요합니다.", - 404: "피쉬탱크를 찾을 수 없습니다.", - 500: "서버 오류로 스프라이트 데이터를 불러오지 못했습니다.", - }); - } -} diff --git a/src/assets/png/Backgrounds/index.ts b/src/assets/png/Backgrounds/index.ts index 0d509aa..4eccdf3 100644 --- a/src/assets/png/Backgrounds/index.ts +++ b/src/assets/png/Backgrounds/index.ts @@ -1,11 +1,11 @@ -import bgOcean from "@/assets/png/Backgrounds/bg-ocean.png"; -import bgDeep1 from "@/assets/png/Backgrounds/bg-deep-1.png"; -import bgDeep2 from "@/assets/png/Backgrounds/bg-deep-2.png"; +import bg1 from "@/assets/png/Backgrounds/bg-deep-1.png"; +import bg2 from "@/assets/png/Backgrounds/bg-deep-2.png"; +import bg3 from "@/assets/png/Backgrounds/bg-ocean.png"; const BACKGROUND_IMAGES: Record = { - "Bg Ocean": bgOcean, - "Bg Deep 1": bgDeep1, - "Bg Deep 2": bgDeep2, + "Bg Ocean": bg1, + "Bg Deep 1": bg2, + "Bg Deep 2": bg3, }; export function getBackgroundImage(name?: string): string | null { diff --git a/src/assets/svg/FishSprites/map.ts b/src/assets/svg/FishSprites/map.ts index 1c84544..0113cdb 100644 --- a/src/assets/svg/FishSprites/map.ts +++ b/src/assets/svg/FishSprites/map.ts @@ -2,6 +2,10 @@ import * as sprites from "."; const SPRITES = sprites as Record; +/** + * 물고기 종 이름으로 SVG를 찾습니다. + * @param species 물고기 종 이름 (예: "SpaceOcto_6", "ShrimpWich_2") + */ export function getFishSpriteSvg(species: string): string { // === 1) LaptopSunfish 특수 처리 =========================== // species가 "LaptopSunfish_4", "LaptopSunfish_999"처럼 들어오면 @@ -23,3 +27,20 @@ export function getFishSpriteSvg(species: string): string { return svg; } + +/** + * group_code와 maturity를 조합해서 SVG를 찾습니다. + * @param groupCode 그룹 코드 (예: "SpaceOcto", "ShrimpWich") + * @param maturity 성장 단계 (1~6) + */ +export function getFishSpriteSvgByGroupAndMaturity(groupCode: string, maturity: number): string { + const fileName = `${groupCode}_${maturity}`; + const svg = SPRITES[fileName]; + if (!svg) { + console.warn( + `[FishTank] Unknown fish: ${fileName} (group_code: ${groupCode}, maturity: ${maturity})`, + ); + return SPRITES["LaptopSunfish"] ?? ""; + } + return svg; +} diff --git a/src/components/MyPage/AquariumFishTable.tsx b/src/components/MyPage/AquariumFishTable.tsx index 57a3cb3..6a09f50 100644 --- a/src/components/MyPage/AquariumFishTable.tsx +++ b/src/components/MyPage/AquariumFishTable.tsx @@ -1,31 +1,98 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import FishIcon from "./FishIcon"; import { Maturity } from "@/types/aquarium"; import { useViewport } from "@/contexts/useViewport"; +import type { Fish } from "@/types/fish"; +import { getFishSpriteSvgByGroupAndMaturity } from "@/assets/svg/FishSprites/map"; -export default function AquariumFishTable() { +interface AquariumFishTableProps { + fishList?: Fish[]; // aquarium preview에 표시되는 물고기 목록 + onSave?: (fishSettings: Array<{ id: number; visible: boolean }>) => Promise; // SAVE & APPLY 버튼 클릭 시 호출 + onSelectionChange?: (fishId: number, visible: boolean) => void; // 토글 변경 시 호출 (preview 즉시 반영용) +} + +// maturity 숫자를 Maturity 문자열로 변환 +const getMaturityFromNumber = (maturity: number): Maturity => { + const maturityMap: Record = { + 1: "Hatchling", + 2: "Juvenile", + 3: "Youngling", + 4: "Adult", + 5: "Advanced", + 6: "Master", + }; + return maturityMap[maturity] || "Hatchling"; +}; + +export default function AquariumFishTable({ + fishList = [], + onSave, + onSelectionChange, +}: AquariumFishTableProps) { const { isMobile, width } = useViewport(); - const rows: { id: string; maturity: Maturity; repo: string; contribution: number }[] = [ - // dummy - { id: "f1", maturity: "Juvenile", repo: "MemoryLane", contribution: 100 }, - { id: "f2", maturity: "Adult", repo: "FlowerGame", contribution: 200 }, - { id: "f3", maturity: "Advanced", repo: "LikeLion", contribution: 300 }, - ]; + // 모든 물고기를 표시 (is_visible_in_aquarium과 관계없이) + const rows = fishList.map((fish) => ({ + id: fish.id.toString(), + fishId: fish.id, // API 호출용 실제 ID + maturity: getMaturityFromNumber(fish.maturity), + repo: fish.repository_name, + contribution: fish.commit_count, + group_code: fish.group_code, + maturityNumber: fish.maturity, + isVisible: fish.is_visible_in_aquarium, // 초기 선택 상태를 위해 저장 + })); + + // 초기 상태: is_visible_in_aquarium이 true인 물고기만 선택된 상태 const [selectedFish, setSelectedFish] = useState>(new Set()); + // rows가 변경될 때마다 초기 선택 상태 업데이트 (is_visible_in_aquarium이 true인 것만 선택) + useEffect(() => { + const visibleIds = rows.filter((r) => r.isVisible).map((r) => r.id); + setSelectedFish(new Set(visibleIds)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rows.length]); + const toggleFishSelection = (fishId: string) => { setSelectedFish((prev) => { const newSet = new Set(prev); - if (newSet.has(fishId)) { + const wasSelected = newSet.has(fishId); + if (wasSelected) { newSet.delete(fishId); } else { newSet.add(fishId); } + // 부모 컴포넌트에 토글 변경 알림 (preview 즉시 반영용) + if (onSelectionChange) { + const fish = rows.find((r) => r.id === fishId); + if (fish) { + onSelectionChange(fish.fishId, !wasSelected); + } + } return newSet; }); }; + const handleSave = async () => { + if (!onSave) return; + + try { + // 모든 물고기에 대한 visibility 설정 생성 + const fishSettings = rows.map((r) => ({ + id: r.fishId, + visible: selectedFish.has(r.id), + })); + + console.log("Saving fish visibility settings:", fishSettings); + await onSave(fishSettings); + console.log("Fish visibility settings saved successfully"); + } catch (error) { + console.error("Failed to save fish visibility:", error); + // 에러는 부모 컴포넌트에서 처리하도록 throw + throw error; + } + }; + // 중간 크기 화면에서도 가로 스크롤 사용 (약 1000px 이하) const useScrollableLayout = isMobile || width < 1000; @@ -45,7 +112,7 @@ export default function AquariumFishTable() { }} > {/* 헤더 행 */} -
+

SELECT

FISH

@@ -62,7 +129,7 @@ export default function AquariumFishTable() { {rows.map((r) => (
{/* SELECT 토글 버튼 */}
@@ -82,8 +149,25 @@ export default function AquariumFishTable() { {/* FISH 썸네일 */}
-
- +
+ {r.group_code && r.maturityNumber ? ( +
+ {/* SVG가 여기에 삽입됨 */} +
+ ) : ( + + )}
@@ -93,10 +177,11 @@ export default function AquariumFishTable() {
{/* REPO 링크 느낌 */} -
+
@@ -110,6 +195,17 @@ export default function AquariumFishTable() { ))}
+ {/* SAVE & APPLY 버튼 */} + {onSave && ( +
+ +
+ )}
); @@ -128,7 +224,7 @@ export default function AquariumFishTable() { }} > {/* 헤더 행 */} -
+

SELECT

FISH

MATURITY

@@ -143,7 +239,7 @@ export default function AquariumFishTable() { {rows.map((r) => (
{/* SELECT 토글 버튼 */}
@@ -163,8 +259,22 @@ export default function AquariumFishTable() { {/* FISH 썸네일 */}
-
- +
+ {r.group_code && r.maturityNumber ? ( +
+ {/* SVG가 여기에 삽입됨 */} +
+ ) : ( + + )}
@@ -172,10 +282,11 @@ export default function AquariumFishTable() {
{r.maturity}
{/* REPO 링크 느낌 */} -
+
@@ -186,6 +297,17 @@ export default function AquariumFishTable() {
))}
+ {/* SAVE & APPLY 버튼 */} + {onSave && ( +
+ +
+ )}
); diff --git a/src/components/MyPage/AquariumSection.tsx b/src/components/MyPage/AquariumSection.tsx index ff9a181..531b128 100644 --- a/src/components/MyPage/AquariumSection.tsx +++ b/src/components/MyPage/AquariumSection.tsx @@ -1,11 +1,21 @@ import { useMemo, useState, useEffect } from "react"; -import AquariumCanvas from "./AquariumCanvas"; +import { AquariumPreview } from "@/components"; import { SubTab } from "./AquariumTabs"; import AquariumBackgroundGrid from "./AquariumBackgroundGrid"; import AquariumItemGrid from "./AquariumItemGrid"; import AquariumFishTable from "./AquariumFishTable"; import { useViewport } from "@/contexts/useViewport"; -import { getMyBackgrounds, applyAquariumBackground, type MyBackground } from "@/apis/aquarium"; +import { + getMyBackgrounds, + applyAquariumBackground, + getAquariumDetail, + updateAquariumFishVisibility, + getMyFishes, + type MyBackground, + type AquariumDetail, + type UserFish, +} from "@/apis/aquarium"; +import type { Fish } from "@/types/fish"; // 배경 이미지 import import bg1 from "@/assets/png/Backgrounds/bg-deep-1.png"; import bg2 from "@/assets/png/Backgrounds/bg-deep-2.png"; @@ -14,32 +24,37 @@ import bg3 from "@/assets/png/Backgrounds/bg-ocean.png"; type Item = { id: string; name: string; src: string }; type BgItem = { id: string; name: string; src: string }; +// 로컬 assets 배경 파일 매핑 (id, code, name 기반) +const localBackgroundMap: Record = { + // id 기반 + "1": bg1, + "2": bg2, + "3": bg3, + // code 기반 + "bg-1": bg1, + "bg-2": bg2, + "bg-3": bg3, + // name 기반 (혹시 모를 경우 대비) + "bg-1.png": bg1, + "bg-2.png": bg2, + "bg-3.png": bg3, +}; + export default function AquariumSection() { const { isMobile, width } = useViewport(); const useVerticalLayout = isMobile || width < 1400; const [tab, setTab] = useState(useVerticalLayout ? "fish" : "background"); // 모바일: fish, 와이드: background - const totalContrib = 12987; // dummy + const [totalContrib, setTotalContrib] = useState(0); + const [aquariumDetail, setAquariumDetail] = useState(null); + const [loadingAquarium, setLoadingAquarium] = useState(true); + const [allFishes, setAllFishes] = useState([]); + // 로컬 토글 상태 (preview 즉시 반영용) + const [localFishVisibility, setLocalFishVisibility] = useState>(new Map()); const [bgCandidates, setBgCandidates] = useState([]); const [loadingBg, setLoadingBg] = useState(true); const [backgroundsData, setBackgroundsData] = useState([]); - // 로컬 assets 배경 파일 매핑 (id, code, name 기반) - const localBackgroundMap: Record = { - // id 기반 - "1": bg1, - "2": bg2, - "3": bg3, - // code 기반 - "bg-1": bg1, - "bg-2": bg2, - "bg-3": bg3, - // name 기반 (혹시 모를 경우 대비) - "bg-1.png": bg1, - "bg-2.png": bg2, - "bg-3.png": bg3, - }; - // API에서 배경 목록 가져오기 useEffect(() => { const fetchBackgrounds = async () => { @@ -89,6 +104,142 @@ export default function AquariumSection() { fetchBackgrounds(); }, []); + // API에서 아쿠아리움 상세 정보 가져오기 + const fetchAquariumDetail = async () => { + try { + setLoadingAquarium(true); + const detail = await getAquariumDetail(); + setAquariumDetail(detail); + + // fish_list의 각 commit_count를 합산 + const totalContributions = detail.fish_list.reduce((sum, fish) => sum + fish.commit_count, 0); + setTotalContrib(totalContributions); + } catch (e) { + console.error("Failed to fetch aquarium detail:", e); + setAquariumDetail(null); + setTotalContrib(0); + } finally { + setLoadingAquarium(false); + } + }; + + useEffect(() => { + fetchAquariumDetail(); + }, []); + + // 모든 물고기 목록 가져오기 (visibility와 관계없이) + useEffect(() => { + const fetchAllFishes = async () => { + try { + const fishes = await getMyFishes(); + setAllFishes(fishes); + // 초기 로컬 visibility 상태 설정 (is_visible_in_aquarium 기반) + const initialVisibility = new Map(); + fishes.forEach((fish) => { + initialVisibility.set(fish.id, fish.is_visible_in_aquarium); + }); + setLocalFishVisibility(initialVisibility); + } catch (e) { + console.error("Failed to fetch all fishes:", e); + setAllFishes([]); + } + }; + + fetchAllFishes(); + }, []); + + // 물고기 visibility 업데이트 핸들러 (SAVE & APPLY 버튼 클릭 시) + const handleFishVisibilityUpdate = async ( + fishSettings: Array<{ id: number; visible: boolean }>, + ) => { + try { + console.log("Calling updateAquariumFishVisibility with:", fishSettings); + await updateAquariumFishVisibility(fishSettings); + console.log("updateAquariumFishVisibility succeeded"); + + // 업데이트 후 아쿠아리움 상세 정보와 모든 물고기 목록 다시 불러오기 + const [, updatedFishes] = await Promise.all([fetchAquariumDetail(), getMyFishes()]); + setAllFishes(updatedFishes); + // 로컬 visibility 상태도 서버 상태로 동기화 + const newVisibility = new Map(); + updatedFishes.forEach((fish) => { + newVisibility.set(fish.id, fish.is_visible_in_aquarium); + }); + setLocalFishVisibility(newVisibility); + + // 성공 메시지 표시 + setMessage("물고기 배치가 성공적으로 저장되었습니다!"); + setTimeout(() => setMessage(null), 3000); + } catch (e) { + console.error("Failed to update fish visibility:", e); + const errorMessage = e instanceof Error ? e.message : "물고기 배치 저장에 실패했습니다."; + setMessage(errorMessage); + setTimeout(() => setMessage(null), 3000); + throw e; // 에러를 다시 throw하여 AquariumFishTable에서 처리 가능하도록 + } + }; + + // background_name을 AquariumPreview가 기대하는 형식으로 변환 + // 예: "bg-deep-1" → "Bg Deep 1", "bg-ocean" → "Bg Ocean" + const convertBackgroundName = (name: string | null | undefined): string | undefined => { + if (!name || name === "기본 배경") return undefined; + + // 백엔드에서 오는 형식: "bg-deep-1", "bg-deep-2", "bg-ocean" 등 + // AquariumPreview가 기대하는 형식: "Bg Deep 1", "Bg Deep 2", "Bg Ocean" + const nameMap: Record = { + "bg-deep-1": "Bg Deep 1", + "bg-deep-2": "Bg Deep 2", + "bg-ocean": "Bg Ocean", + "Bg Deep 1": "Bg Deep 1", + "Bg Deep 2": "Bg Deep 2", + "Bg Ocean": "Bg Ocean", + }; + + return nameMap[name] || name; + }; + + // UserFish를 Fish로 변환 (테이블용 - 모든 물고기) + // aquariumDetail.fish_list에서 commit_count를 가져와서 매핑 + const convertUserFishToFishList = (userFishes: UserFish[]): Fish[] => { + // aquariumDetail.fish_list에서 id를 키로 하는 commit_count 맵 생성 + const commitCountMap = new Map(); + if (aquariumDetail?.fish_list) { + aquariumDetail.fish_list.forEach((fish) => { + commitCountMap.set(fish.id, fish.commit_count); + }); + } + + return userFishes.map((fish) => ({ + id: fish.id, + name: fish.species_name, + group_code: fish.group_code, + maturity: fish.maturity, + repository_name: fish.repository_full_name, + commit_count: commitCountMap.get(fish.id) ?? fish.commit_count ?? 0, // aquariumDetail에서 가져오거나 UserFish에서 가져오거나 0 + unlocked_at: null, // UserFish에는 unlocked_at이 없으므로 null + is_visible_in_aquarium: localFishVisibility.get(fish.id) ?? fish.is_visible_in_aquarium, // 로컬 상태 우선 사용 + is_visible_in_fishtank: fish.is_visible_in_fishtank, + github_username: fish.github_username, + })); + }; + + // Preview용 fishList 생성 (로컬 visibility 상태 기반) + const getPreviewFishList = (): Fish[] => { + const allFishList = convertUserFishToFishList(allFishes); + return allFishList.filter( + (fish) => localFishVisibility.get(fish.id) ?? fish.is_visible_in_aquarium, + ); + }; + + // 토글 선택 상태 변경 핸들러 + const handleFishSelectionChange = (fishId: number, visible: boolean) => { + setLocalFishVisibility((prev) => { + const newMap = new Map(prev); + newMap.set(fishId, visible); + return newMap; + }); + }; + const itemCandidates: Item[] = useMemo( () => [ { id: "it1", name: "Corals 1", src: "/images/items/coral-1.png" }, @@ -100,20 +251,10 @@ export default function AquariumSection() { [], ); - const [appliedBgId, setAppliedBgId] = useState(null); const [selectedBgId, setSelectedBgId] = useState(null); - const [appliedItemId, setAppliedItemId] = useState(null); const [selectedItemId, setSelectedItemId] = useState(null); const [message, setMessage] = useState(null); - const appliedBgSrc = - (appliedBgId && bgCandidates.find((b) => b.id === appliedBgId)?.src) || - "/images/aquarium_example.png"; - - const appliedItemSrc = appliedItemId - ? itemCandidates.find((i) => i.id === appliedItemId)?.src - : undefined; - const handleApply = async () => { if (tab === "background" && selectedBgId) { if (selectedBgId === "locked") { @@ -145,9 +286,16 @@ export default function AquariumSection() { // background_id를 직접 사용 (API가 내부에서 처리하도록) // TODO: API가 background_id를 받도록 수정하거나, OwnBackground.id를 응답에 포함 await applyAquariumBackground(background.background_id); - setAppliedBgId(selectedBgId); setMessage("배경이 성공적으로 적용되었습니다!"); setTimeout(() => setMessage(null), 3000); + + // 배경 적용 후 아쿠아리움 detail 다시 가져오기 + try { + const updatedDetail = await getAquariumDetail(); + setAquariumDetail(updatedDetail); + } catch (e) { + console.warn("Failed to refresh aquarium detail after apply:", e); + } } catch (e) { const errorMessage = e instanceof Error ? e.message : "배경 적용에 실패했습니다."; setMessage(errorMessage); @@ -159,7 +307,7 @@ export default function AquariumSection() { setTimeout(() => setMessage(null), 3000); // 3초 후 메시지 자동 제거 return; } - setAppliedItemId(selectedItemId); + // TODO: 아이템 적용 기능 구현 setMessage(null); // 성공적으로 적용되면 메시지 제거 } }; @@ -179,16 +327,28 @@ export default function AquariumSection() {
- {/* 상단 수족관 미리보기 (배경 + 아이템 오버레이) */} + {/* 상단 수족관 미리보기 */}
- + {loadingAquarium ? ( +
+

로딩 중...

+
+ ) : aquariumDetail ? ( + + ) : ( +
+

+ 아쿠아리움 정보를 불러올 수 없습니다 +

+
+ )}
@@ -227,29 +387,32 @@ export default function AquariumSection() {
{tab !== "fish" && ( - +
+ {/* 메시지 표시 영역 - APPLY 버튼 위에 absolute로 고정 */} + {message && ( +
+ {message} +
+ )} + +
)}
- {/* 잠겨있는 아이템/배경 선택 시 메시지 표시 영역 */} - {message && ( -
-
- {message} -
-
- )} - {/* 탭 컨텐츠 */}
{tab === "fish" && (
- +
)} {tab === "background" && @@ -315,29 +478,44 @@ export default function AquariumSection() {
- {/* 잠겨있는 아이템/배경 선택 시 메시지 표시 영역 */} - {message && ( -
-
+
+ {/* 메시지 표시 영역 - APPLY 버튼 위에 absolute로 고정 */} + {message && ( +
{message}
-
- )} - - + )} + +
{/* 본문: 캔버스 / 그리드 === */}
- {/* 좌측: AquariumCanvas */} + {/* 좌측: AquariumPreview */}
- + {loadingAquarium ? ( +
+

로딩 중...

+
+ ) : aquariumDetail ? ( + + ) : ( +
+

아쿠아리움 정보를 불러올 수 없습니다

+
+ )}

Repo contributions: {totalContrib}

@@ -374,13 +552,11 @@ export default function AquariumSection() { {/* 하단: Fish Table */}
- - +
diff --git a/src/components/MyPage/FishTankSection.tsx b/src/components/MyPage/FishTankSection.tsx index 19b2509..a331d48 100644 --- a/src/components/MyPage/FishTankSection.tsx +++ b/src/components/MyPage/FishTankSection.tsx @@ -1,23 +1,39 @@ -import { useRef, useState, useMemo, useEffect } from "react"; +import { useState, useMemo, useEffect } from "react"; import RepoSelect from "./RepoSelect"; -import FishTankCanvas from "./FishTankCanvas"; +import { FishTankPreview } from "@/components"; import GrowthTimeline from "./GrowthTimeline"; import AquariumBackgroundGrid from "./AquariumBackgroundGrid"; import AquariumItemGrid from "./AquariumItemGrid"; -import { CanvasSize, RepoInfo } from "@/types/aquarium"; +import { RepoInfo } from "@/types/aquarium"; import { getMyBackgrounds, getFishtankDetail, applyFishtankBackground, - getFishtankSprites, type MyBackground, } from "@/apis/fishtank"; import { useViewport } from "@/contexts/useViewport"; +import type { Fish } from "@/types/fish"; // 배경 이미지 import import bg1 from "@/assets/png/Backgrounds/bg-deep-1.png"; import bg2 from "@/assets/png/Backgrounds/bg-deep-2.png"; import bg3 from "@/assets/png/Backgrounds/bg-ocean.png"; +// 로컬 assets 배경 파일 매핑 (id, code, name 기반) +const localBackgroundMap: Record = { + // id 기반 + "1": bg1, + "2": bg2, + "3": bg3, + // code 기반 + "bg-1": bg1, + "bg-2": bg2, + "bg-3": bg3, + // name 기반 (혹시 모를 경우 대비) + "bg-1.png": bg1, + "bg-2.png": bg2, + "bg-3.png": bg3, +}; + type Item = { id: string; name: string; src: string }; type BgItem = { id: string; name: string; src: string }; type SubTab = "timeline" | "background" | "items"; @@ -25,7 +41,6 @@ type SubTab = "timeline" | "background" | "items"; export default function FishTankSection() { const { isMobile, width } = useViewport(); const [repo, setRepo] = useState(null); - const size: CanvasSize = { width: 700, height: 400 }; const [contrib, setContrib] = useState(0); const [contributionFishes, setContributionFishes] = useState< Array<{ @@ -42,6 +57,11 @@ export default function FishTankSection() { }; }> >([]); + const [fishtankDetail, setFishtankDetail] = useState<{ + repository_full_name: string; + background_name: string; + fish_list: Fish[]; + } | null>(null); // 중간 크기 화면에서도 세로 레이아웃 사용 (캔버스 700px + 우측 500px + 패딩 = 약 1400px 필요) const useVerticalLayout = isMobile || width < 1400; @@ -52,8 +72,6 @@ export default function FishTankSection() { // 배경/아이템 관련 상태 const [tab, setTab] = useState("background"); - const [appliedBgId, setAppliedBgId] = useState(null); - const [appliedBgUrl, setAppliedBgUrl] = useState(null); const [selectedBgId, setSelectedBgId] = useState(null); // const [appliedItemId, setAppliedItemId] = useState(null); const [selectedItemId, setSelectedItemId] = useState(null); @@ -62,22 +80,6 @@ export default function FishTankSection() { const [loadingBg, setLoadingBg] = useState(true); const [backgroundsData, setBackgroundsData] = useState([]); - // 로컬 assets 배경 파일 매핑 (id, code, name 기반) - const localBackgroundMap: Record = { - // id 기반 - "1": bg1, - "2": bg2, - "3": bg3, - // code 기반 - "bg-1": bg1, - "bg-2": bg2, - "bg-3": bg3, - // name 기반 (혹시 모를 경우 대비) - "bg-1.png": bg1, - "bg-2.png": bg2, - "bg-3.png": bg3, - }; - // API에서 배경 목록 가져오기 useEffect(() => { const fetchBackgrounds = async () => { @@ -132,80 +134,79 @@ export default function FishTankSection() { const fetchFishtankData = async () => { if (!repo) { setContrib(0); - setAppliedBgId(null); + setFishtankDetail(null); return; } try { console.log("Fetching fishtank detail for repo:", repo.id, repo.fullName); + // repo.id는 string이므로 숫자로 변환 + const repoIdNum = parseInt(repo.id, 10); + if (isNaN(repoIdNum)) { + console.error("Invalid repo ID:", repo.id); + throw new Error(`Invalid repository ID: ${repo.id}`); + } const fishtankDetail = await getFishtankDetail(repo.id); console.log("Fishtank detail received:", fishtankDetail); + console.log( + "Fish list details:", + fishtankDetail.fish_list.map((fish) => ({ + id: fish.id, + name: fish.name, + group_code: fish.group_code, + maturity: fish.maturity, + github_username: fish.github_username, + })), + ); - // contributors의 각 commit_count를 합산 - const totalContributions = fishtankDetail.contributors.reduce( - (sum, contributor) => sum + contributor.commit_count, + // fishtankDetail state에 저장 + setFishtankDetail({ + repository_full_name: fishtankDetail.repository_full_name, + background_name: fishtankDetail.background_name, + fish_list: fishtankDetail.fish_list, + }); + + // fish_list의 각 commit_count를 합산 + const totalContributions = fishtankDetail.fish_list.reduce( + (sum, fish) => sum + fish.commit_count, 0, ); console.log("Total contributions:", totalContributions); setContrib(totalContributions); - // ContributionFish 데이터 추출 - const fishes = fishtankDetail.contributors - .filter((c) => c.fish !== null) - .map((c) => ({ - id: c.fish!.id, - username: c.user, - commit_count: c.commit_count, - species: c.fish!.species, - })); + // ContributionFish 데이터 추출 (fish_list를 기반으로) + // fish_list는 이미 is_visible_in_fishtank=true인 것만 반환되므로 필터링 불필요 + const fishes = fishtankDetail.fish_list.map((fish) => ({ + id: fish.id, + username: fish.repository_name, // repository_name을 username으로 사용 + commit_count: fish.commit_count, + species: { + id: fish.id, + name: fish.name, + maturity: fish.maturity, + required_commits: 0, // API 응답에 없으므로 기본값 + svg_template: "", // API 응답에 없으므로 빈 문자열 (나중에 로컬에서 로드) + group_code: fish.group_code, + }, + })); setContributionFishes(fishes); console.log("ContributionFishes:", fishes); - - // 현재 적용된 배경 가져오기 - // background_id를 사용하여 로컬 assets 매칭 - try { - const spriteData = await getFishtankSprites(repo.id); - if (spriteData.background_id) { - // background_id를 사용하여 로컬 assets 매칭 - const bgIdStr = spriteData.background_id.toString(); - const localBg = localBackgroundMap[bgIdStr]; - - if (localBg) { - // 로컬 assets 사용 - setAppliedBgUrl(null); - setAppliedBgId(bgIdStr); - } else { - // 로컬 assets가 없으면 bgCandidates에서 찾기 - const matchedBg = bgCandidates.find((bg) => bg.id === bgIdStr); - if (matchedBg) { - setAppliedBgUrl(null); - setAppliedBgId(bgIdStr); - } else { - // 매칭되는 배경이 없으면 기본 이미지 사용 (background_url 사용 안 함 - 404 방지) - setAppliedBgUrl(null); - setAppliedBgId(null); - } - } - } else { - setAppliedBgUrl(null); - setAppliedBgId(null); - } - } catch (e) { - console.warn("Failed to fetch fishtank sprites:", e); - // 배경 로드 실패는 무시 (기본 배경 사용) - setAppliedBgUrl(null); - setAppliedBgId(null); - } - } catch { + } catch (e) { // 피쉬탱크가 없는 경우 레포지토리 정보의 contributions 사용 - console.warn("Fishtank not found for repo:", repo.id, repo.fullName); + const errorMessage = e instanceof Error ? e.message : "Unknown error"; + console.warn( + "Fishtank not found for repo:", + repo.id, + repo.fullName, + "Error:", + errorMessage, + ); console.warn("Using repository contributions as fallback:", repo.contributions); // 레포지토리 정보의 contributions를 사용 (피쉬탱크가 없어도 해당 레포의 commit 수는 알 수 있음) setContrib(repo.contributions || 0); - setAppliedBgId(null); - setAppliedBgUrl(null); setContributionFishes([]); + setFishtankDetail(null); } }; @@ -223,11 +224,24 @@ export default function FishTankSection() { [], ); - // 적용된 배경 이미지 URL 계산 - const appliedBgSrc = - appliedBgUrl || - (appliedBgId && bgCandidates.find((b) => b.id === appliedBgId)?.src) || - "/images/fishtank_example.png"; + // background_name을 FishTankPreview가 기대하는 형식으로 변환 + // 예: "bg-deep-1" → "Bg Deep 1", "bg-ocean" → "Bg Ocean" + const convertBackgroundName = (name: string | null | undefined): string | undefined => { + if (!name || name === "기본 배경") return undefined; + + // 백엔드에서 오는 형식: "bg-deep-1", "bg-deep-2", "bg-ocean" 등 + // FishTankPreview가 기대하는 형식: "Bg Deep 1", "Bg Deep 2", "Bg Ocean" + const nameMap: Record = { + "bg-deep-1": "Bg Deep 1", + "bg-deep-2": "Bg Deep 2", + "bg-ocean": "Bg Ocean", + "Bg Deep 1": "Bg Deep 1", + "Bg Deep 2": "Bg Deep 2", + "Bg Ocean": "Bg Ocean", + }; + + return nameMap[name] || name; + }; const handleApply = async () => { if (tab === "background" && selectedBgId) { @@ -263,42 +277,21 @@ export default function FishTankSection() { // API는 Background의 id를 요구함 await applyFishtankBackground(repo.id, background.background_id); - setAppliedBgId(selectedBgId); - // 선택한 배경의 src를 직접 사용 - const selectedBg = bgCandidates.find((b) => b.id === selectedBgId); - if (selectedBg) { - setAppliedBgUrl(null); // bgCandidates의 src 사용 - } - // 배경 적용 후 현재 적용된 배경 다시 가져오기 + setMessage("배경이 성공적으로 적용되었습니다!"); + setTimeout(() => setMessage(null), 3000); + + // 배경 적용 후 fishtank detail 다시 가져오기 try { - const spriteData = await getFishtankSprites(repo.id); - if (spriteData.background_id) { - // background_id를 사용하여 로컬 assets 매칭 - const bgIdStr = spriteData.background_id.toString(); - const localBg = localBackgroundMap[bgIdStr]; - - if (localBg) { - // 로컬 assets 사용 - setAppliedBgUrl(null); - setAppliedBgId(bgIdStr); - } else { - // 로컬 assets가 없으면 bgCandidates에서 찾기 - const matchedBg = bgCandidates.find((bg) => bg.id === bgIdStr); - if (matchedBg) { - setAppliedBgUrl(null); - setAppliedBgId(bgIdStr); - } else { - // 매칭되는 배경이 없으면 기본 이미지 사용 (background_url 사용 안 함 - 404 방지) - setAppliedBgUrl(null); - setAppliedBgId(null); - } - } - } + const updatedDetail = await getFishtankDetail(repo.id); + // fishtankDetail state 업데이트 + setFishtankDetail({ + repository_full_name: updatedDetail.repository_full_name, + background_name: updatedDetail.background_name, + fish_list: updatedDetail.fish_list, + }); } catch (e) { console.warn("Failed to refresh background after apply:", e); } - setMessage("배경이 성공적으로 적용되었습니다!"); - setTimeout(() => setMessage(null), 3000); } catch (e) { const errorMessage = e instanceof Error ? e.message : "배경 적용에 실패했습니다."; setMessage(errorMessage); @@ -315,8 +308,6 @@ export default function FishTankSection() { } }; - const canvasRef = useRef(null); - // 모바일 뷰일 때 Aquarium 페이지와 동일한 레이아웃 사용 if (useVerticalLayout) { return ( @@ -346,7 +337,20 @@ export default function FishTankSection() { {/* 상단 캔버스 미리보기 */}
- + {fishtankDetail ? ( + + ) : ( +
+

레포지토리를 선택해주세요

+
+ )}
@@ -502,9 +506,22 @@ export default function FishTankSection() { {/* 본문: 캔버스 / 그리드 */}
- {/* 좌측: FishTankCanvas */} + {/* 좌측: FishTankPreview */}
- + {fishtankDetail ? ( + + ) : ( +
+

레포지토리를 선택해주세요

+
+ )}

Repo contributions: {contrib}

diff --git a/src/components/TankRenderer/AquariumPreview.tsx b/src/components/TankRenderer/AquariumPreview.tsx index 5bf2835..0757904 100644 --- a/src/components/TankRenderer/AquariumPreview.tsx +++ b/src/components/TankRenderer/AquariumPreview.tsx @@ -2,7 +2,7 @@ import React from "react"; import TankRenderer from "./TankRenderer"; import FishSprite from "./FishSprite"; -import { getFishSpriteSvg } from "@/assets/svg/FishSprites/map"; +import { getFishSpriteSvg, getFishSpriteSvgByGroupAndMaturity } from "@/assets/svg/FishSprites/map"; import { getBackgroundImage } from "@/assets/png/Backgrounds"; import { useAuth } from "@/auth/AuthContext"; import type { Fish } from "@/types/fish"; @@ -55,12 +55,15 @@ export default function AquariumPreview({ {/* 물고기 렌더링 */} {visibleFish.map((fish) => { - const speciesKey = `${fish.group_code}_${fish.maturity}`; - const svgSource = getFishSpriteSvg(speciesKey); - - const repoLabel = fish.repository_name?.includes("/") - ? fish.repository_name.split("/").pop()! - : fish.repository_name; + // group_code와 maturity를 조합해서 SVG 찾기 (우선순위 1) + // 만약 name이 정확히 "GroupCode_Maturity" 형식이면 그것도 시도 + let svgSource: string; + if (fish.group_code && fish.maturity) { + svgSource = getFishSpriteSvgByGroupAndMaturity(fish.group_code, fish.maturity); + } else { + // fallback: name으로 찾기 + svgSource = getFishSpriteSvg(fish.name); + } return ( { - const speciesKey = `${fish.group_code}_${fish.maturity}`; - const svgSource = getFishSpriteSvg(speciesKey); + // group_code와 maturity를 조합해서 SVG 찾기 (우선순위 1) + let svgSource: string; + const hasGroupCode = fish.group_code && fish.group_code.trim() !== ""; + const hasMaturity = fish.maturity && fish.maturity > 0; + + if (hasGroupCode && hasMaturity) { + const expectedKey = `${fish.group_code}_${fish.maturity}`; + svgSource = getFishSpriteSvgByGroupAndMaturity(fish.group_code, fish.maturity); + + // 디버깅: 매핑 확인 + const defaultSvg = SPRITES["LaptopSunfish"] ?? ""; + if (!svgSource || svgSource === defaultSvg) { + console.warn(`[FishTankPreview] Failed to find SVG for fish:`, { + id: fish.id, + name: fish.name, + group_code: fish.group_code, + maturity: fish.maturity, + expected: expectedKey, + availableKeys: Object.keys(SPRITES).filter((k) => k.startsWith(fish.group_code)), + }); + } else { + console.log(`[FishTankPreview] Successfully found SVG for fish:`, { + id: fish.id, + name: fish.name, + group_code: fish.group_code, + maturity: fish.maturity, + key: expectedKey, + }); + } + } else { + // fallback: name으로 찾기 + console.warn( + `[FishTankPreview] Missing group_code or maturity for fish, using name fallback:`, + { + id: fish.id, + name: fish.name, + group_code: fish.group_code, + maturity: fish.maturity, + hasGroupCode, + hasMaturity, + }, + ); + svgSource = getFishSpriteSvg(fish.name); + } return ( diff --git a/src/types/fish.ts b/src/types/fish.ts index a2786d8..5c80102 100644 --- a/src/types/fish.ts +++ b/src/types/fish.ts @@ -4,12 +4,12 @@ export interface Fish { id: number; name: string; // 물고기 종 이름 - github_username: string; //소유자 + github_username?: string | null; // 기여자의 GitHub Username (optional - null 가능) group_code: string; // 물고기 종 그룹 코드 maturity: number; // 성장 단계 (1~6) repository_name: string; // 출처 레포지토리 풀네임 commit_count: number; // 해당 레포에 기여한 커밋 수 - unlocked_at: string; // 해금 시각 (ISO string) + unlocked_at: string | null; // 해금 시각 (ISO string, null 가능) is_visible_in_aquarium: boolean; is_visible_in_fishtank: boolean; }