diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 063929c..483fc1d 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -21,7 +21,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v3 with: - node-version: 18 + node-version: 22 - name: Install Dependencies run: npm install simple-git semver @actions/github @actions/core dotenv diff --git a/components.json b/components.json index 8d294be..3de5874 100644 --- a/components.json +++ b/components.json @@ -5,17 +5,17 @@ "tsx": true, "tailwind": { "config": "tailwind.config.ts", - "css": "app/globals.css", + "css": "src/app/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "aliases": { - "components": "@/app/components", - "utils": "@/lib/utils", - "ui": "@/app/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" + "components": "@/src/app/components", + "utils": "@/src/lib/utils", + "ui": "@/src/app/components/ui", + "lib": "@/src/lib", + "hooks": "@/src/hooks" }, "iconLibrary": "lucide" } \ No newline at end of file diff --git a/docs/api_docs.md b/docs/api_docs.md index bbe2129..c4a3d4f 100644 --- a/docs/api_docs.md +++ b/docs/api_docs.md @@ -176,7 +176,7 @@ API 서버 로그를 조회합니다. --- -### GET /admin/item/items/group/:group +#### GET /admin/item/items/group/:group 특정 그룹의 아이템 목록을 조회합니다. @@ -212,7 +212,7 @@ API 서버 로그를 조회합니다. --- -### GET /admin/item/items/name/:name +#### GET /admin/item/items/name/:name 특정 이름을 포함하는 아이템 목록을 조회합니다. @@ -249,7 +249,7 @@ API 서버 로그를 조회합니다. --- -### POST /admin/item +#### POST /admin/item 아이템을 추가합니다. @@ -288,7 +288,7 @@ Response (201) --- -### PUT /admin/item/:id +#### PUT /admin/item/:id 아이템 정보를 수정합니다. @@ -330,7 +330,7 @@ Response (201) --- -### DELETE /admin/item/:id +#### DELETE /admin/item/:id 아이템을 삭제합니다. @@ -343,6 +343,167 @@ Response (201) **Response (204)** No Content - 성공적으로 삭제됨 +--- +### User (Admin) + +#### GET /admin/user/users +등록된 사용자 목록을 조회합니다. + +**Query Parameters** +| Name | Type | Description | +| ----- | ------ | ---------------- | +| page | number | 페이지 번호 | + +**Response** + +```ts +{ + items: { + id: string; + nickname: string; + exp: number; + observedAt: string; // ISO format + exordial: string; + level: number; + isPublic: boolean; + isLastOnlineHidden: boolean; + }[]; + totalCount: number; + currentPage: number; + totalPages: number; +} +``` + +--- + +#### GET /admin/user/users/id/:id +특정 사용자 정보를 조회합니다. + +**Path Parameter** +| Name | Type | Description | +| ---- | ------ | ----------- | +| id | string | 사용자 ID | + +**Response (200)** + +```ts +{ + items: { + id: string; + nickname: string; + exp: number; + observedAt: string; // ISO format + exordial: string; + level: number; + isPublic: boolean; + isLastOnlineHidden: boolean; + }[]; + totalCount: 1 | 0; // 1 if user exists, otherwise 0 + currentPage: 1; + totalPages: 1; +} +``` + + +#### GET /admin/user/users/nickname/:nickname +특정 닉네임을 포함하는 사용자 정보를 조회합니다. + +**Path Parameter** +| Name | Type | Description | +| ----- | ------ | ------------- | +| nickname | string | 사용자 닉네임 | + +**Response (200)** + +```ts +{ + items: { + id: string; + nickname: string; + exp: number; + observedAt: string; // ISO format + exordial: string; + level: number; + isPublic: boolean; + isLastOnlineHidden: boolean; + }[]; + totalCount: number; // 닉네임을 포함하는 사용자 수 + currentPage: number; + totalPages: number; +} +``` + +--- +#### PUT /admin/user/public-status/:id +특정 사용자의 공개 상태를 수정합니다. + +**Path Parameter** +| Name | Type | Description | +| ---- | ------ | ----------- | +| id | string | 사용자 ID | + +**Request Body** +| Field | Type | Description | +| ------------ | ------ | --------------- | +| isPublic | boolean| 공개 여부 | + +```ts +{ + isPublic: boolean; +} +``` + +**Response (200)** + +```ts +{ + id: string; + nickname: string; + exp: number; + observedAt: string; // ISO format + exordial: string; + level: number; + isPublic: boolean; + isLastOnlineHidden: boolean; +} +``` + +--- + +#### PUT /admin/user/last-online-hidden/:id + +특정 사용자의 마지막 온라인 숨김 상태를 수정합니다. + +**Path Parameter** +| Name | Type | Description | +| ---- | ------ | ----------- | +| id | string | 사용자 ID | + +**Request Body** +| Field | Type | Description | +| ------------------ | ------ | --------------- | +| isLastOnlineHidden | boolean| 마지막 온라인 숨김 여부 | + +```ts +{ + isLastOnlineHidden: boolean; +} +``` + +**Response (200)** + +```ts +{ + id: string; + nickname: string; + exp: number; + observedAt: string; // ISO format + exordial: string; + level: number; + isPublic: boolean; + isLastOnlineHidden: boolean; +} +``` --- @@ -485,6 +646,24 @@ No Content - 성공적으로 삭제됨 } ``` +--- +### GET /profile/force-refresh/:userId +특정 유저의 프로필 정보를 강제 새로고침을 요청합니다. + +**Path Parameter** +| Name | Type | Description | +| ------ | ------ | ----------- | +| userId | string | 사용자 ID | + +**Response (200)** + +```ts +{ + message: "Profile refresh enqueued", + status: 200 +} +``` + --- ## Item API diff --git a/next.config.ts b/next.config.ts index a21e9d1..effc6b8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -17,6 +17,12 @@ const nextConfig: NextConfig = { port: '', pathname: '/img/**', }, + { + protocol: 'https', + hostname: 'api.solidloop-studio.xyz', + port: '', + pathname: '/kkuko/**', + } ], localPatterns: [ { diff --git a/scripts/release.js b/scripts/release.js index 46a0d8a..6cbb744 100644 --- a/scripts/release.js +++ b/scripts/release.js @@ -95,12 +95,15 @@ async function run() { // 6. 체인지로그 내용 생성 const date = new Date().toISOString().split('T')[0]; - let header = `# [${nextVersion}] - ${date}`; + const versionHeader = isReleaseMode ? `v${currentVersion}` : nextVersion; + let header = `# [${versionHeader}] - ${date}`; // Repo URL이 있으면 비교 링크 생성 if (config.repoUrl) { - const prevVersionTag = `v${currentVersion}`; - header = `# [${nextVersion}](${config.repoUrl}/compare/${prevVersionTag}...${nextVersion}) - ${date}`; + // 릴리즈 모드일 때는 이전 태그와 현재 버전 비교 필요 (구현 생략 - 단순화) + // PR 모드일 때는 현재 버전(old) .. 다음 버전(new) + const prevVersionTag = isReleaseMode ? '...' : `v${currentVersion}`; + header = `# [${versionHeader}](${config.repoUrl}/compare/${prevVersionTag}...${versionHeader}) - ${date}`; } let changelogBody = `${header}\n\n`; @@ -130,7 +133,8 @@ async function run() { // --- 실행 분기: Release Mode (GitHub Release 등록) vs Normal Mode (PR 생성) --- if (isReleaseMode) { - await createGitHubRelease(currentVersion, changelogBody, isDryRun); + // 릴리즈 모드에서는 이미 버전이 업데이트 된 상태이므로 currentVersion을 사용 + await createGitHubRelease(`v${currentVersion}`, changelogBody, isReleaseMode && isDryRun); return; } diff --git a/src/app/admin/api-server/ApiServerMangerHome.tsx b/src/app/admin/api-server/ApiServerMangerHome.tsx index e6ef299..89e19b4 100644 --- a/src/app/admin/api-server/ApiServerMangerHome.tsx +++ b/src/app/admin/api-server/ApiServerMangerHome.tsx @@ -19,6 +19,11 @@ export default function ApiServerAdminHome() { title: 'Items 관리', description: '아이템 상태 확인 및 수정', href: '/admin/api-server/items', + }, + { + title: 'Users 관리', + description: '사용자 목록 조회 및 상태 수정', + href: '/admin/api-server/users', } ]; diff --git a/src/app/admin/api-server/api.ts b/src/app/admin/api-server/api.ts index ccddcda..68576f6 100644 --- a/src/app/admin/api-server/api.ts +++ b/src/app/admin/api-server/api.ts @@ -8,7 +8,10 @@ import type { ItemsResponse, Item, CreateItemRequest, - UpdateItemRequest + UpdateItemRequest, + UsersResponse, + User, + UpdateUserPublicStatusRequest } from './types'; import { SCM } from '@/src/app/lib/supabaseClient'; import zlib from 'zlib'; @@ -99,6 +102,50 @@ export const deleteItem = async (id: string): Promise => { ); }; +// User APIs +export const fetchUsers = async (page: number = 1): Promise => { + const headers = await getAuthHeaders(); + const response = await axios.get( + `${BASE_URL}/admin/user/users`, + { + headers, + params: { page } + } + ); + return response.data; +}; + +export const fetchUserById = async (id: string): Promise => { + const headers = await getAuthHeaders(); + const response = await axios.get( + `${BASE_URL}/admin/user/users/id/${id}`, + { headers } + ); + return response.data; +}; + + +export const searchUsersByNickname = async (nickname: string): Promise => { + const headers = await getAuthHeaders(); + const response = await axios.get( + `${BASE_URL}/admin/user/users/nickname/${encodeURIComponent(nickname)}`, + { headers } + ); + return response.data; +}; + +export const updateUserPublicStatus = async (id: string, isPublic: boolean): Promise => { + const headers = await getAuthHeaders(); + const data: UpdateUserPublicStatusRequest = { isPublic }; + const response = await axios.put( + `${BASE_URL}/admin/user/public-status/${id}`, + data, + { headers } + ); + return response.data; +}; + + export const searchItems = async (name: string, page: number = 1): Promise => { const headers = await getAuthHeaders(); const response = await axios.get( @@ -123,6 +170,21 @@ export const searchItemsByGroup = async (group: string, page: number = 1): Promi return response.data; }; +export interface UpdateUserLastOnlineHiddenStatusRequest { + isLastOnlineHidden: boolean; +} + +export const updateUserLastOnlineHiddenStatus = async (id: string, isLastOnlineHidden: boolean): Promise => { + const headers = await getAuthHeaders(); + const data: UpdateUserLastOnlineHiddenStatusRequest = { isLastOnlineHidden }; + const response = await axios.put( + `${BASE_URL}/admin/user/last-online-hidden/${id}`, + data, + { headers } + ); + return response.data; +}; + // Logs APIs const isGzip = (u8: Uint8Array) => u8 && u8.length >= 2 && u8[0] === 0x1f && u8[1] === 0x8b; diff --git a/src/app/admin/api-server/items/ItemsMangeHome.tsx b/src/app/admin/api-server/items/ItemsMangeHome.tsx index 5875994..5782dbf 100644 --- a/src/app/admin/api-server/items/ItemsMangeHome.tsx +++ b/src/app/admin/api-server/items/ItemsMangeHome.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { Plus, Search } from 'lucide-react' +import { ArrowLeft, Plus, Search } from 'lucide-react' import axios, { AxiosError } from 'axios' import { Button } from '@/src/app/components/ui/button' @@ -23,6 +23,7 @@ import { } from "@/src/app/components/ui/select" import * as API from '../api' +import Link from 'next/link' export default function ItemsManageHome() { const queryClient = useQueryClient() @@ -163,6 +164,12 @@ export default function ItemsManageHome() { return (
+ + +

Item Management

+ +
+

User Management

+
+ +
+ +
+ + setSearchTerm(e.target.value)} + className="pl-9" + /> +
+
+ + + + {/* Pagination */} + {data && data.totalPages > 1 && ( +
+ + + Page {data.currentPage} of {data.totalPages} + + +
+ )} + + + + {error && ( + setError(null)} + /> + )} +
+ ) +} diff --git a/src/app/admin/api-server/users/_components/EditUserModal.tsx b/src/app/admin/api-server/users/_components/EditUserModal.tsx new file mode 100644 index 0000000..c7f9a98 --- /dev/null +++ b/src/app/admin/api-server/users/_components/EditUserModal.tsx @@ -0,0 +1,133 @@ +'use client' + +import { useEffect, useState } from 'react' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/src/app/components/ui/dialog' +import { Button } from '@/src/app/components/ui/button' +import { Input } from '@/src/app/components/ui/input' +import { Label } from '@/src/app/components/ui/label' +import { Checkbox } from '@/src/app/components/ui/checkbox' +import { User, UserInput } from './types' + +interface EditUserModalProps { + open: boolean + onOpenChange: (open: boolean) => void + user: User | null + onSave: (user: UserInput) => void + isSaving: boolean + readOnly?: boolean +} + +export default function EditUserModal({ + open, + onOpenChange, + user, + onSave, + isSaving, + readOnly = false, +}: EditUserModalProps) { + const title = readOnly ? 'View User' : 'Edit User' + + const [isPublic, setIsPublic] = useState(false) + const [isLastOnlineHidden, setIsLastOnlineHidden] = useState(false) + + useEffect(() => { + if (open && user) { + setIsPublic(user.isPublic) + setIsLastOnlineHidden(user.isLastOnlineHidden) + } + }, [open, user]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSave({ isPublic, isLastOnlineHidden }) + } + + if (!user) return null + + return ( + + + + {title} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ setIsPublic(!!checked)} + disabled={readOnly} + /> + +
+
+ +
+ +
+ setIsLastOnlineHidden(!!checked)} + disabled={readOnly} + /> + +
+
+ + + + {!readOnly && ( + + )} + +
+
+
+ ) +} diff --git a/src/app/admin/api-server/users/_components/UsersTable.tsx b/src/app/admin/api-server/users/_components/UsersTable.tsx new file mode 100644 index 0000000..ddf34c7 --- /dev/null +++ b/src/app/admin/api-server/users/_components/UsersTable.tsx @@ -0,0 +1,162 @@ +'use client' + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/src/app/components/ui/table' +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, +} from '@tanstack/react-table' +import { Button } from '@/src/app/components/ui/button' +import { Edit2 } from 'lucide-react' +import { useMemo } from 'react' +import { User } from './types' +import Spinner from '@/src/app/components/Spinner' + +interface UsersTableProps { + items: User[] + isLoading: boolean + onEdit: (item: User) => void + onRowClick: (item: User) => void +} + +export default function UsersTable({ items, isLoading, onEdit, onRowClick }: UsersTableProps) { + const columns = useMemo[]>(() => { + // If items are empty, we can return empty columns or pre-defined columns. + // But dynamic assumes we have at least one item to know the shape. + // Let's define manual columns if empty is not guaranteed or to enforce order. + // For simplicity, sticking to dynamic like ItemsTable. + if (!items || items.length === 0) return [] + + const firstItem = items[0] + const keys = Object.keys(firstItem) as (keyof User)[] + + const generatedColumns: ColumnDef[] = keys.map((key) => { + return { + accessorKey: key, + header: key.charAt(0).toUpperCase() + key.slice(1), + cell: ({ getValue }) => { + const value = getValue() + + if (key === 'observedAt') { + try { + return new Date(String(value)).toLocaleString() + } catch { + return String(value) + } + } + if (typeof value === 'boolean') { + return {value ? 'TRUE' : 'FALSE'} + } + if (typeof value === 'string' && value.length > 50) { + return
{value}
+ } + return String(value) + }, + } + }) + + // Add actions column + generatedColumns.push({ + id: 'actions', + header: 'Actions', + cell: ({ row }) => { + const item = row.original + return ( +
e.stopPropagation()}> + +
+ ) + }, + }) + + return generatedColumns + }, [items, onEdit]) + + const table = useReactTable({ + data: items, + columns, + getCoreRowModel: getCoreRowModel(), + }) + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (!items || items.length === 0) { + return
No users found.
+ } + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + onRowClick(row.original)} + className="cursor-pointer hover:bg-muted/50 transition-colors" + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ ) +} diff --git a/src/app/admin/api-server/users/_components/types.ts b/src/app/admin/api-server/users/_components/types.ts new file mode 100644 index 0000000..29f9a41 --- /dev/null +++ b/src/app/admin/api-server/users/_components/types.ts @@ -0,0 +1,4 @@ +import { User } from '../../types'; + +export type UserInput = Pick; +export type { User }; diff --git a/src/app/admin/api-server/users/page.tsx b/src/app/admin/api-server/users/page.tsx new file mode 100644 index 0000000..90f8578 --- /dev/null +++ b/src/app/admin/api-server/users/page.tsx @@ -0,0 +1,3 @@ +import UsersManageHome from "./UsersManageHome"; + +export default UsersManageHome; diff --git a/src/app/kkuko/profile/KkukoProfile.tsx b/src/app/kkuko/profile/KkukoProfile.tsx index 7de64e6..c964329 100644 --- a/src/app/kkuko/profile/KkukoProfile.tsx +++ b/src/app/kkuko/profile/KkukoProfile.tsx @@ -11,6 +11,9 @@ import ProfileStats from './components/ProfileStats'; import ProfileRecords from './components/ProfileRecords'; import ItemModal from './components/ItemModal'; import ErrorModal from '../../components/ErrModal'; +import CompleteModal from '../../components/CompleteModal'; +import FailModal from '../../components/FailModal'; +import ConfirmModal from '../../components/ConfirmModal'; export default function KkukoProfile() { const searchParams = useSearchParams(); @@ -28,10 +31,41 @@ export default function KkukoProfile() { recentSearches, fetchProfile, removeFromRecentSearches, - selectProfile + selectProfile, + requestForceRefresh } = useKkukoProfile(); const [showItemModal, setShowItemModal] = useState(false); + + // Modal state for force refresh + const [showConfirmModal, setShowConfirmModal] = useState(false); + const [showCompleteModal, setShowCompleteModal] = useState(false); + const [showFailModal, setShowFailModal] = useState(false); + const [modalMessage, setModalMessage] = useState(''); + const [isRefreshing, setIsRefreshing] = useState(false); + + const handleRefreshRequest = () => { + if (isRefreshing) return; + setShowConfirmModal(true); + }; + + const handleConfirmRefresh = async () => { + setShowConfirmModal(false); + setIsRefreshing(true); + if (!profileData?.user?.id) return; + + try { + await requestForceRefresh(profileData.user.id); + setShowCompleteModal(true); + } catch (error: unknown) { + console.error('Refresh error:', error); + setModalMessage('알 수 없는 오류가 발생했습니다.'); + setShowFailModal(true); + } finally { + setIsRefreshing(false); + } + }; + // Handle URL query parameters to trigger fetch useEffect(() => { @@ -139,6 +173,8 @@ export default function KkukoProfile() { profileData={profileData} itemsData={itemsData} expRank={expRank} + isRefreshing={isRefreshing} + onRefreshRequest={handleRefreshRequest} /> {/* Equipment Section */} @@ -147,6 +183,7 @@ export default function KkukoProfile() { onShowDetail={() => setShowItemModal(true)} /> + {/* Records Section */} )} + {/* Force Refresh Modals */} + setShowCompleteModal(false)} + title="갱신 요청 완료" + description="갱신 요청이 완료되었습니다. 잠시 후 새로고침 해주세요." + /> + + setShowFailModal(false)} + title="갱신 요청 실패" + description={modalMessage} + /> + {/* Warning Message */}

⚠️ 해당 데이터는 비공식 API를 사용하여 만들었으며 데이터가 항상 최신이거나 정확하다고 할 수 없습니다. 참고용으로만 사용해주세요.

+ + {/* Force Refresh Modals */} + setShowConfirmModal(false)} + /> + + setShowCompleteModal(false)} + title="갱신 요청 완료" + description="갱신 요청이 완료되었습니다. 잠시 후 새로고침 해주세요." + /> + + setShowFailModal(false)} + title="갱신 요청 실패" + description={modalMessage} + />
); } \ No newline at end of file diff --git a/src/app/kkuko/profile/components/ItemModal.tsx b/src/app/kkuko/profile/components/ItemModal.tsx index b2733d9..5370f80 100644 --- a/src/app/kkuko/profile/components/ItemModal.tsx +++ b/src/app/kkuko/profile/components/ItemModal.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { ItemInfo, ProfileData, isSpecialOptions, ItemOption, SpecialOptions } from '@/src/app/types/kkuko.types'; import TryRenderImg from '../../shared/components/TryRenderImg' import { getSlotName, extractColorFromLabel, parseDescription, getOptionName, formatNumber } from '../utils/profileHelper'; -import { NICKNAME_COLORS } from '../../shared/lib/const'; +import { NICKNAME_COLORS, OPTION_NAMES } from '../../shared/lib/const'; interface ItemModalProps { itemsData: ItemInfo[]; @@ -21,15 +21,20 @@ export default function ItemModal({ itemsData, profileData, onClose }: ItemModal ) + const filterAndMapOptions = (opts: Record) => { + return Object.entries(opts) + .filter(([k, v]) => { + if (!(k in OPTION_NAMES) || v === undefined || v === null) return false; + return !isNaN(Number(v)); + }) + .map(([k, v]) => itemOptionUI(k, Number(v))); + }; + if (isSpecialOptions(options)) { const relevantOptions = Date.now() >= options.date ? options.after : options.before; - return Object.entries(relevantOptions).filter(([_, v]) => v !== undefined && typeof v === 'number').map(([k, v]) => - itemOptionUI(k, v as number) - ); + return filterAndMapOptions(relevantOptions); } else { - return Object.entries(options).filter(([_, v]) => v !== undefined && typeof v === 'number').map(([k, v]) => - itemOptionUI(k, v as number) - ); + return filterAndMapOptions(options); } } @@ -78,7 +83,7 @@ export default function ItemModal({ itemsData, profileData, onClose }: ItemModal
void; } -export default function ProfileHeader({ profileData, itemsData, expRank }: ProfileHeaderProps) { +export default function ProfileHeader({ + profileData, + itemsData, + expRank, + isRefreshing, + onRefreshRequest +}: ProfileHeaderProps) { const isDarkTheme = typeof window !== 'undefined' ? localStorage.getItem('theme') === 'dark' : false; const lvImgPlaceholder = () => ( @@ -44,7 +54,7 @@ export default function ProfileHeader({ profileData, itemsData, expRank }: Profi
} - url={`/api/kkuko/image?url=https://cdn.kkutu.co.kr/img/kkutu/moremi/badge/${item.id}.png`} + url={`https://api.solidloop-studio.xyz/kkuko/image?url=https://cdn.kkutu.co.kr/img/kkutu/moremi/badge/${item.id}.png`} alt={item.name} width={40} height={40} @@ -89,7 +99,7 @@ export default function ProfileHeader({ profileData, itemsData, expRank }: Profi
-
+
+ + | 정보 비공개 요청 diff --git a/src/app/kkuko/profile/components/ProfileStats.tsx b/src/app/kkuko/profile/components/ProfileStats.tsx index dfa4ff6..987e9db 100644 --- a/src/app/kkuko/profile/components/ProfileStats.tsx +++ b/src/app/kkuko/profile/components/ProfileStats.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { ItemInfo } from '@/src/app/types/kkuko.types'; import { calculateTotalOptions, getOptionName, formatNumber } from '../utils/profileHelper'; +import { OPTION_NAMES } from '../../shared/lib/const'; interface ProfileStatsProps { itemsData: ItemInfo[]; @@ -14,16 +15,16 @@ export default function ProfileStats({ itemsData, onShowDetail }: ProfileStatsPr

착용 아이템 정보

- + }
- {Object.entries(totalOptions).map(([key, value]) => ( + {Object.entries(totalOptions).filter(([key]) => key in OPTION_NAMES).map(([key, value]) => (

{getOptionName(key)}

{value > 0 ? '+' : ''}{formatNumber(value)}{key[0] === 'g' ? '%p' : ''}

diff --git a/src/app/kkuko/profile/hooks/useKkukoProfile.ts b/src/app/kkuko/profile/hooks/useKkukoProfile.ts index 43c0c5b..0b275d8 100644 --- a/src/app/kkuko/profile/hooks/useKkukoProfile.ts +++ b/src/app/kkuko/profile/hooks/useKkukoProfile.ts @@ -6,7 +6,8 @@ import { fetchProfile as fetchProfileApi, fetchProfileByNickname as fetchProfileByNicknameApi, fetchItems as fetchItemsApi, - fetchExpRank as fetchExpRankApi + fetchExpRank as fetchExpRankApi, + fetchForceRefresh as fetchForceRefreshApi } from '../../shared/lib/api'; import { Equipment, Mode, ProfileData } from '@/src/app/types/kkuko.types'; import { useRecentSearches } from './useRecentSearches'; @@ -209,6 +210,7 @@ export const useKkukoProfile = () => { recentSearches, fetchProfile, removeFromRecentSearches, - selectProfile: setSelectedProfile + selectProfile: setSelectedProfile, + requestForceRefresh: fetchForceRefreshApi }; }; diff --git a/src/app/kkuko/profile/utils/profileHelper.ts b/src/app/kkuko/profile/utils/profileHelper.ts index 3530784..7e3aed8 100644 --- a/src/app/kkuko/profile/utils/profileHelper.ts +++ b/src/app/kkuko/profile/utils/profileHelper.ts @@ -44,19 +44,22 @@ export const calculateTotalOptions = (itemsData: ItemInfo[]) => { const totals: Record = {}; itemsData.forEach(item => { - if (isSpecialOptions(item.options)) { - const relevantOptions = Date.now() >= item.options.date ? item.options.after : item.options.before; - Object.entries(relevantOptions).forEach(([key, value]) => { - if (value !== undefined && typeof value === 'number' && !isNaN(value)) { - totals[key] = (totals[key] || 0) + Number(value) * (key[0] === 'g' ? 100 : 1); + const processOptions = (options: Record) => { + Object.entries(options).forEach(([key, value]) => { + if (key in OPTION_NAMES && value !== undefined && value !== null) { + const numValue = Number(value); + if (!isNaN(numValue)) { + totals[key] = (totals[key] || 0) + numValue * (key[0] === 'g' ? 100 : 1); + } } }); + }; + + if (isSpecialOptions(item.options)) { + const relevantOptions = Date.now() >= item.options.date ? item.options.after : item.options.before; + processOptions(relevantOptions); } else { - Object.entries(item.options).forEach(([key, value]) => { - if (value !== undefined && typeof value === 'number' && !isNaN(value)) { - totals[key] = (totals[key] || 0) + Number(value) * (key[0] === 'g' ? 100 : 1); - } - }); + processOptions(item.options); } }); diff --git a/src/app/kkuko/shared/components/ProfileAvatar.tsx b/src/app/kkuko/shared/components/ProfileAvatar.tsx index d3a3ad6..9bf1081 100644 --- a/src/app/kkuko/shared/components/ProfileAvatar.tsx +++ b/src/app/kkuko/shared/components/ProfileAvatar.tsx @@ -37,7 +37,7 @@ export default function ProfileAvatar({ profileData, itemsData }: ProfileAvatarP const leftHandItem = itemsBySlot['Mlhand']; if (leftHandItem) { const imageName = leftHandItem.id; - const imageUrl = `/api/kkuko/image?url=https://cdn.kkutu.co.kr/img/kkutu/moremi/hand/${imageName}.png`; + const imageUrl = `https://api.solidloop-studio.xyz/kkuko/img?url=https://cdn.kkutu.co.kr/img/kkutu/moremi/hand/${imageName}.png`; layers.push({ key: `hand-left-${index}`, @@ -51,7 +51,7 @@ export default function ProfileAvatar({ profileData, itemsData }: ProfileAvatarP const rightHandItem = itemsBySlot['Mrhand']; if (rightHandItem) { const imageName = rightHandItem.id; - const imageUrl = `/api/kkuko/image?url=https://cdn.kkutu.co.kr/img/kkutu/moremi/hand/${imageName}.png`; + const imageUrl = `https://api.solidloop-studio.xyz/kkuko/img?url=https://cdn.kkutu.co.kr/img/kkutu/moremi/hand/${imageName}.png`; layers.push({ key: `hand-right-${index}`, @@ -67,7 +67,7 @@ export default function ProfileAvatar({ profileData, itemsData }: ProfileAvatarP if (item) { const imageName = item.name === 'def' ? 'def' : item.id; - const imageUrl = `/api/kkuko/image?url=https://cdn.kkutu.co.kr/img/kkutu/moremi/${group}/${imageName}.png`; + const imageUrl = `https://api.solidloop-studio.xyz/kkuko/img?url=https://cdn.kkutu.co.kr/img/kkutu/moremi/${group}/${imageName}.png`; layers.push({ key: `${group}-${index}`, @@ -77,7 +77,7 @@ export default function ProfileAvatar({ profileData, itemsData }: ProfileAvatarP }); } else if (group !== 'badge' && item === undefined) { const itemId = 'def'; - const imageUrl = `/api/kkuko/image?url=https://cdn.kkutu.co.kr/img/kkutu/moremi/${group}/${itemId}.png`; + const imageUrl = `https://api.solidloop-studio.xyz/kkuko/img?url=https://cdn.kkutu.co.kr/img/kkutu/moremi/${group}/${itemId}.png`; layers.push({ key: `${group}-${index}`, url: imageUrl, diff --git a/src/app/kkuko/shared/lib/api.ts b/src/app/kkuko/shared/lib/api.ts index a6c5e83..35671ea 100644 --- a/src/app/kkuko/shared/lib/api.ts +++ b/src/app/kkuko/shared/lib/api.ts @@ -45,6 +45,10 @@ export async function fetchExpRank(userId: string) { }); } +export async function fetchForceRefresh(userId: string) { + return await client.get(`/profile/force-refresh/${userId}`); +} + export async function fetchRanking( mode: string, page: number = 1,