From 69be1ba0203e14d0f68b1cb0fce6ad5a02930029 Mon Sep 17 00:00:00 2001 From: jtw Date: Fri, 13 Feb 2026 23:51:50 +0900 Subject: [PATCH 01/13] =?UTF-8?q?ci:=20=EC=9B=8C=ED=81=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=20nodejs=20=EB=B2=84=EC=A0=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/publish-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 0588fbfdaa1467ca48413f6067eb54f73cf8e6ea Mon Sep 17 00:00:00 2001 From: jtw Date: Sun, 15 Feb 2026 23:22:13 +0900 Subject: [PATCH 02/13] =?UTF-8?q?ci:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=B6=80=EB=B6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/release.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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; } From a8a0d266640fea907f55c240ff4548209589a078 Mon Sep 17 00:00:00 2001 From: jtw Date: Sun, 15 Feb 2026 23:25:01 +0900 Subject: [PATCH 03/13] =?UTF-8?q?chore:=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/components.json b/components.json index 8d294be..612439d 100644 --- a/components.json +++ b/components.json @@ -11,11 +11,11 @@ "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 From 3a1f2ab0ff99bb7c6209f7e5f9448f946b2fb5f7 Mon Sep 17 00:00:00 2001 From: jtw Date: Fri, 20 Feb 2026 18:13:23 +0900 Subject: [PATCH 04/13] =?UTF-8?q?chore:=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8.json=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components.json b/components.json index 612439d..3de5874 100644 --- a/components.json +++ b/components.json @@ -5,7 +5,7 @@ "tsx": true, "tailwind": { "config": "tailwind.config.ts", - "css": "app/globals.css", + "css": "src/app/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" From 3a71709abdc38acbfe1b36df32dc69dd6bfe9c4b Mon Sep 17 00:00:00 2001 From: jtw Date: Fri, 20 Feb 2026 18:20:01 +0900 Subject: [PATCH 05/13] =?UTF-8?q?docs:=20api=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api_docs.md | 75 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 5 deletions(-) diff --git a/docs/api_docs.md b/docs/api_docs.md index bbe2129..e97447e 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,71 @@ 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; +} +``` + +--- +#### 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; +} +``` --- From 40f720d920efe5bcd5ec808bd1f0293a34cc56f4 Mon Sep 17 00:00:00 2001 From: jtw Date: Sat, 21 Feb 2026 23:48:18 +0900 Subject: [PATCH 06/13] =?UTF-8?q?docs:=20api=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api_docs.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/docs/api_docs.md b/docs/api_docs.md index e97447e..b6fd174 100644 --- a/docs/api_docs.md +++ b/docs/api_docs.md @@ -374,6 +374,65 @@ No Content - 성공적으로 삭제됨 } ``` +--- + +#### 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 특정 사용자의 공개 상태를 수정합니다. From 451a6f55cfa22b2bf4ef9254f98ccc255c13335e Mon Sep 17 00:00:00 2001 From: jtw Date: Sun, 22 Feb 2026 00:16:49 +0900 Subject: [PATCH 07/13] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/api-server/ApiServerMangerHome.tsx | 5 + src/app/admin/api-server/api.ts | 49 ++++- .../admin/api-server/items/ItemsMangeHome.tsx | 9 +- src/app/admin/api-server/types.ts | 22 ++ .../api-server/users/UsersManageHome.tsx | 199 ++++++++++++++++++ .../users/_components/EditUserModal.tsx | 114 ++++++++++ .../users/_components/UsersTable.tsx | 162 ++++++++++++++ .../api-server/users/_components/types.ts | 4 + src/app/admin/api-server/users/page.tsx | 3 + 9 files changed, 565 insertions(+), 2 deletions(-) create mode 100644 src/app/admin/api-server/users/UsersManageHome.tsx create mode 100644 src/app/admin/api-server/users/_components/EditUserModal.tsx create mode 100644 src/app/admin/api-server/users/_components/UsersTable.tsx create mode 100644 src/app/admin/api-server/users/_components/types.ts create mode 100644 src/app/admin/api-server/users/page.tsx 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..f86abc1 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( 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..44fba07 --- /dev/null +++ b/src/app/admin/api-server/users/_components/EditUserModal.tsx @@ -0,0 +1,114 @@ +'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) + + useEffect(() => { + if (open && user) { + setIsPublic(user.isPublic) + } + }, [open, user]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSave({ isPublic }) + } + + if (!user) return null + + return ( + + + + {title} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ setIsPublic(!!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..26cea6e --- /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; From b634676f11188223bed6a20f2d52e3e961fa6a06 Mon Sep 17 00:00:00 2001 From: jtw Date: Sun, 22 Feb 2026 00:41:49 +0900 Subject: [PATCH 08/13] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=ED=8C=A8=EC=B9=AD=20url=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.ts | 6 ++++++ src/app/kkuko/shared/components/ProfileAvatar.tsx | 8 ++++---- 2 files changed, 10 insertions(+), 4 deletions(-) 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/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, From 9129dbdbf9e1a271bc7d40c370cfbd612a30fc0a Mon Sep 17 00:00:00 2001 From: jtw Date: Sun, 22 Feb 2026 00:51:43 +0900 Subject: [PATCH 09/13] =?UTF-8?q?feat:=20user=EA=B4=80=EB=A6=AC=EC=97=90?= =?UTF-8?q?=EC=84=9C=20isLastOnlineHidden=EB=8F=84=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api_docs.md | 37 +++++++++++++++++++ src/app/admin/api-server/api.ts | 15 ++++++++ .../api-server/users/UsersManageHome.tsx | 10 ++++- .../users/_components/EditUserModal.tsx | 21 ++++++++++- .../api-server/users/_components/types.ts | 2 +- 5 files changed, 81 insertions(+), 4 deletions(-) diff --git a/docs/api_docs.md b/docs/api_docs.md index b6fd174..c3c5ff5 100644 --- a/docs/api_docs.md +++ b/docs/api_docs.md @@ -470,6 +470,43 @@ No Content - 성공적으로 삭제됨 --- +#### 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; +} +``` + +--- + ## User API ### GET /profile/total diff --git a/src/app/admin/api-server/api.ts b/src/app/admin/api-server/api.ts index f86abc1..68576f6 100644 --- a/src/app/admin/api-server/api.ts +++ b/src/app/admin/api-server/api.ts @@ -170,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/users/UsersManageHome.tsx b/src/app/admin/api-server/users/UsersManageHome.tsx index d3ee03a..8acb4fc 100644 --- a/src/app/admin/api-server/users/UsersManageHome.tsx +++ b/src/app/admin/api-server/users/UsersManageHome.tsx @@ -64,8 +64,14 @@ export default function UsersManageHome() { }) const updateMutation = useMutation({ - mutationFn: (vars: { id: string, input: UserInput }) => { - return API.updateUserPublicStatus(vars.id, vars.input.isPublic) + mutationFn: async (vars: { id: string, input: UserInput }) => { + // We call both endpoints. + // If one fails, the mutation fails. + // Ideally should check what changed, but calling both is safe if idempotent-like (setting value). + await API.updateUserPublicStatus(vars.id, vars.input.isPublic) + if (vars.input.isLastOnlineHidden !== undefined) { + await API.updateUserLastOnlineHiddenStatus(vars.id, vars.input.isLastOnlineHidden) + } }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }) diff --git a/src/app/admin/api-server/users/_components/EditUserModal.tsx b/src/app/admin/api-server/users/_components/EditUserModal.tsx index 44fba07..c7f9a98 100644 --- a/src/app/admin/api-server/users/_components/EditUserModal.tsx +++ b/src/app/admin/api-server/users/_components/EditUserModal.tsx @@ -34,16 +34,18 @@ export default function EditUserModal({ 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 }) + onSave({ isPublic, isLastOnlineHidden }) } if (!user) return null @@ -96,6 +98,23 @@ export default function EditUserModal({
+ +
+ +
+ setIsLastOnlineHidden(!!checked)} + disabled={readOnly} + /> + +
+
+ | 정보 비공개 요청 diff --git a/src/app/kkuko/profile/components/ProfileStats.tsx b/src/app/kkuko/profile/components/ProfileStats.tsx index dfa4ff6..66ebf52 100644 --- a/src/app/kkuko/profile/components/ProfileStats.tsx +++ b/src/app/kkuko/profile/components/ProfileStats.tsx @@ -14,12 +14,12 @@ export default function ProfileStats({ itemsData, onShowDetail }: ProfileStatsPr

착용 아이템 정보

- + }
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/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, From bd59a6cdfb304f41bc1a388c96199369a3f89253 Mon Sep 17 00:00:00 2001 From: jtw Date: Sun, 22 Feb 2026 01:17:37 +0900 Subject: [PATCH 11/13] =?UTF-8?q?fix:=20=EC=95=8C=EB=A0=A4=EC=A7=84=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=EB=A7=8C=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/kkuko/profile/components/ItemModal.tsx | 6 +++--- src/app/kkuko/profile/components/ProfileStats.tsx | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/app/kkuko/profile/components/ItemModal.tsx b/src/app/kkuko/profile/components/ItemModal.tsx index b2733d9..9cb5410 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[]; @@ -23,11 +23,11 @@ export default function ItemModal({ itemsData, profileData, onClose }: ItemModal 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]) => + return Object.entries(relevantOptions).filter(([k, v]) => k in OPTION_NAMES && v !== undefined && typeof v === 'number').map(([k, v]) => itemOptionUI(k, v as number) ); } else { - return Object.entries(options).filter(([_, v]) => v !== undefined && typeof v === 'number').map(([k, v]) => + return Object.entries(options).filter(([k, v]) => k in OPTION_NAMES && v !== undefined && typeof v === 'number').map(([k, v]) => itemOptionUI(k, v as number) ); } diff --git a/src/app/kkuko/profile/components/ProfileStats.tsx b/src/app/kkuko/profile/components/ProfileStats.tsx index 66ebf52..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[]; @@ -23,7 +24,7 @@ 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' : ''}

From 23391d889b9e3e0a01d1edd90929e9655c09ebcc Mon Sep 17 00:00:00 2001 From: jtw Date: Sun, 22 Feb 2026 01:23:55 +0900 Subject: [PATCH 12/13] =?UTF-8?q?fix:=20=EA=B0=92=EC=9D=B4=20=EB=AC=B8?= =?UTF-8?q?=EC=9E=90=EC=97=B4=EB=A1=9C=EB=90=9C=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kkuko/profile/components/ItemModal.tsx | 17 +++++++++----- src/app/kkuko/profile/utils/profileHelper.ts | 23 +++++++++++-------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/app/kkuko/profile/components/ItemModal.tsx b/src/app/kkuko/profile/components/ItemModal.tsx index 9cb5410..cae033c 100644 --- a/src/app/kkuko/profile/components/ItemModal.tsx +++ b/src/app/kkuko/profile/components/ItemModal.tsx @@ -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(([k, v]) => k in OPTION_NAMES && v !== undefined && typeof v === 'number').map(([k, v]) => - itemOptionUI(k, v as number) - ); + return filterAndMapOptions(relevantOptions); } else { - return Object.entries(options).filter(([k, v]) => k in OPTION_NAMES && v !== undefined && typeof v === 'number').map(([k, v]) => - itemOptionUI(k, v as number) - ); + return filterAndMapOptions(options); } } diff --git a/src/app/kkuko/profile/utils/profileHelper.ts b/src/app/kkuko/profile/utils/profileHelper.ts index 3530784..c48da98 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); } }); From cce1ebea75d962d7964a4c4b16934651e50ed92a Mon Sep 17 00:00:00 2001 From: jtw Date: Sun, 22 Feb 2026 01:28:31 +0900 Subject: [PATCH 13/13] =?UTF-8?q?chore:=20limt=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/kkuko/profile/KkukoProfile.tsx | 4 ++-- src/app/kkuko/profile/components/ItemModal.tsx | 4 ++-- src/app/kkuko/profile/components/ProfileHeader.tsx | 2 +- src/app/kkuko/profile/utils/profileHelper.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/kkuko/profile/KkukoProfile.tsx b/src/app/kkuko/profile/KkukoProfile.tsx index ace70bd..c964329 100644 --- a/src/app/kkuko/profile/KkukoProfile.tsx +++ b/src/app/kkuko/profile/KkukoProfile.tsx @@ -57,9 +57,9 @@ export default function KkukoProfile() { try { await requestForceRefresh(profileData.user.id); setShowCompleteModal(true); - } catch (error: any) { + } catch (error: unknown) { console.error('Refresh error:', error); - setModalMessage(error?.response?.data?.message || '알 수 없는 오류가 발생했습니다.'); + setModalMessage('알 수 없는 오류가 발생했습니다.'); setShowFailModal(true); } finally { setIsRefreshing(false); diff --git a/src/app/kkuko/profile/components/ItemModal.tsx b/src/app/kkuko/profile/components/ItemModal.tsx index cae033c..5370f80 100644 --- a/src/app/kkuko/profile/components/ItemModal.tsx +++ b/src/app/kkuko/profile/components/ItemModal.tsx @@ -21,7 +21,7 @@ export default function ItemModal({ itemsData, profileData, onClose }: ItemModal
) - const filterAndMapOptions = (opts: Record) => { + const filterAndMapOptions = (opts: Record) => { return Object.entries(opts) .filter(([k, v]) => { if (!(k in OPTION_NAMES) || v === undefined || v === null) return false; @@ -83,7 +83,7 @@ export default function ItemModal({ itemsData, profileData, onClose }: ItemModal
} - 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} diff --git a/src/app/kkuko/profile/utils/profileHelper.ts b/src/app/kkuko/profile/utils/profileHelper.ts index c48da98..7e3aed8 100644 --- a/src/app/kkuko/profile/utils/profileHelper.ts +++ b/src/app/kkuko/profile/utils/profileHelper.ts @@ -44,7 +44,7 @@ export const calculateTotalOptions = (itemsData: ItemInfo[]) => { const totals: Record = {}; itemsData.forEach(item => { - const processOptions = (options: Record) => { + const processOptions = (options: Record) => { Object.entries(options).forEach(([key, value]) => { if (key in OPTION_NAMES && value !== undefined && value !== null) { const numValue = Number(value);