Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
/.pnp
.pnp.*
.yarn/*
/.next
!.yarn/patches
!.yarn/plugins
!.yarn/releases
Expand Down Expand Up @@ -42,4 +43,7 @@ next-env.d.ts

# Test
app/test/
app/api/test
app/api/test

# Idea
/.idea
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# [v1.4.0](https://github.com/SolidLoop-studio/kkuko-utils/compare/v1.3.0...v1.4.0) - 2025-12-20

## fix
- ([b91fdd2](https://github.com/SolidLoop-studio/kkuko-utils/commit/b91fdd2)) - 릴리즈 nodejs 버전 업데이트
- ([e603226](https://github.com/SolidLoop-studio/kkuko-utils/commit/e603226)) - 서비스 제공자 이름 변경, 코드 라이센스 변경

## feat
- ([768ef96](https://github.com/SolidLoop-studio/kkuko-utils/commit/768ef96)) - implement automated release workflow and update dependencies

10 changes: 5 additions & 5 deletions app/AutoLogin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ const AutoLogin = () => {

if (!data || !data.session || error) return;

const { data: ddata, error: err } = await SCM.get().userById(data.session.user.id);
const { data: dbdata, error: err } = await SCM.get().userById(data.session.user.id);

if (err || !ddata) return;
if (err || !dbdata) return;

dispatch(
userAction.setInfo({
username: ddata.nickname,
role: ddata.role ?? "guest",
uuid: ddata.id,
username: dbdata.nickname,
role: dbdata.role ?? "guest",
uuid: dbdata.id,
})
);
}
Expand Down
4 changes: 2 additions & 2 deletions app/ErrorPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { ErrorMessage } from "./types/type";
import { useRouter } from "next/navigation";

const ErrorPage:React.FC<{e:ErrorMessage}> = ({e}) => {
const [errork,setError] = useState<ErrorMessage | null>(null);
const [error,setError] = useState<ErrorMessage | null>(null);
const router = useRouter();

const goBack = () => {
Expand All @@ -19,7 +19,7 @@ const ErrorPage:React.FC<{e:ErrorMessage}> = ({e}) => {

return (
<div className="flex flex-col flex-grow min-h-screen bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
{errork && <ErrorModal error={e} onClose={()=>setError(null)} /> }
{error && <ErrorModal error={e} onClose={()=>setError(null)} /> }

<button
onClick={goBack}
Expand Down
8 changes: 4 additions & 4 deletions app/admin/add-words/AddWordsHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ export default function WordsAddHome() {
const insertThemeMap: Record<string, string[]> = {}; // 단어 - 추가된 주제이름들 맵

// 단어 주제 추가
const { data: insertedThemesData, error: inseredThemeError } = await supabaseInQueryChunk(
const { data: insertedThemesData, error: insertedThemeError } = await supabaseInQueryChunk(
themesAddQuery,
async (chunk) => {
const r = await SCM.add().wordsThemes(chunk);
Expand All @@ -412,7 +412,7 @@ export default function WordsAddHome() {
}
}
)
if (inseredThemeError) return makeError(inseredThemeError)
if (insertedThemeError) return makeError(insertedThemeError)
for (const data of insertedThemesData ?? []) {
insertThemeMap[data.words.word] = [...(insertThemeMap[data.words.word] ?? []), data.themes.name]
}
Expand All @@ -438,9 +438,9 @@ export default function WordsAddHome() {
// JSON에 없는 단어-주제 쌍 제거
setCurrentTask('파일에 없는 단어-주제 쌍 제거 중...');
if (wordThemeDelQuery.length > 0) {
const { data: wordthemeDeletedData, error: wordThemeDeleteError } = await SCM.delete().wordTheme(wordThemeDelQuery);
const { data: wordThemeDeletedData, error: wordThemeDeleteError } = await SCM.delete().wordTheme(wordThemeDelQuery);
if (wordThemeDeleteError) return makeError(wordThemeDeleteError);
for (const data of wordthemeDeletedData) {
for (const data of wordThemeDeletedData) {
const docsId = themeDocsInfo[data.theme_name]
if (docsId) {
docsLogsQuery.push({
Expand Down
6 changes: 3 additions & 3 deletions app/admin/del-words/DelWordsHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export default function WordsDelHome() {

setCurrentTask("필요한 정보 가져오는 중...");
setProgress(0);
const { data: docsDatas, error: docsDataError } = await SCM.get().allDocs();
const { data: docsData, error: docsDataError } = await SCM.get().allDocs();
if (docsDataError) return makeError(docsDataError);

const { data: waitWords, error: waitWordsError } = await SCM.get().allWaitWords('delete');
Expand All @@ -156,10 +156,10 @@ export default function WordsDelHome() {
const themeDocsInfo: Record<string, number> = {};


docsDatas.filter(({ typez }) => typez === "letter").forEach(({ id, name }) => {
docsData.filter(({ typez }) => typez === "letter").forEach(({ id, name }) => {
letterDocsInfo[name] = id
})
docsDatas.filter(({ typez }) => typez === "theme").forEach(({ id, name }) => {
docsData.filter(({ typez }) => typez === "theme").forEach(({ id, name }) => {
themeDocsInfo[name] = id
})

Expand Down
2 changes: 1 addition & 1 deletion app/admin/logs/AdminLogsHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ export default function AdminLogsHome({ initialWordLogs, initialDocsLogs, allDoc
setLoading(true);
try {
if (selectedTab === "word_logs") {
const { data, error } = await SCM.get().logsByFillter({
const { data, error } = await SCM.get().logsByFilter({
filterState: wordLogState,
filterType: wordLogType,
from: 0,
Expand Down
2 changes: 1 addition & 1 deletion app/admin/logs/AdminLogsWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export default function AdminLogsWrapper(){
{ data: allDocsLogs, error: docsLogsError },
{ data: allDocs, error: allDocsError }
] = await Promise.all([
SCM.get().logsByFillter({ filterState: "all", filterType: "all", from: 0, to: 999 }),
SCM.get().logsByFilter({ filterState: "all", filterType: "all", from: 0, to: 999 }),
SCM.get().docsLogsByFilter({ logType: "all", from: 0, to: 999 }),
SCM.get().allDocs()
]);
Expand Down
8 changes: 4 additions & 4 deletions app/admin/request-words/AdminRequestHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ type WordRequest = {
word_id?: number; // 주제 변경 요청에서만 사용
}

export default function AdminHome({ requestDatas, refreshFn }: { requestDatas: WordRequest[], refreshFn: () => Promise<void> }) {
export default function AdminHome({ requestData: requestData, refreshFn }: { requestData: WordRequest[], refreshFn: () => Promise<void> }) {
const [selectedTab, setSelectedTab] = useState<string>("all");
const [selectedRequests, setSelectedRequests] = useState<Set<number>>(new Set());
const [selectedThemes, setSelectedThemes] = useState<Record<number, Set<number>>>({});
Expand All @@ -85,7 +85,7 @@ export default function AdminHome({ requestDatas, refreshFn }: { requestDatas: W
}, []);

// 요청 타입별 필터링
const filteredRequests = requestDatas.filter(request => {
const filteredRequests = requestData.filter(request => {
if (selectedTab === "all") return true;
return request.request_type === selectedTab;
});
Expand Down Expand Up @@ -169,7 +169,7 @@ export default function AdminHome({ requestDatas, refreshFn }: { requestDatas: W

// 승인 처리할 요청과 선택된 주제 정보 구성
const requestsToApprove = Array.from(selectedRequests).map(reqId => {
const request = requestDatas.find(r => r.id === reqId);
const request = requestData.find(r => r.id === reqId);
const selectedThemeIds = selectedThemes[reqId] || new Set<number>();

// allThemes에서 선택된 주제 정보 가져오기
Expand Down Expand Up @@ -427,7 +427,7 @@ export default function AdminHome({ requestDatas, refreshFn }: { requestDatas: W

// 거절할 처리할 요청과 선택된 주제 정보 구성
const requestsToReject = Array.from(selectedRequests).map(reqId => {
const request = requestDatas.find(r => r.id === reqId);
const request = requestData.find(r => r.id === reqId);
const selectedThemeIds = selectedThemes[reqId] || new Set<number>();

return {
Expand Down
8 changes: 4 additions & 4 deletions app/admin/request-words/AdminWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type WordRequest = {
export default function AdminHomeWrapper(){
const { loadingState, updateLoadingState } = useLoadingState();
const [errorMessage,setErrorMessage] = useState<string|null>(null);
const [waitDatas,setWaitDatas] = useState<WordRequest[] | null>(null);
const [waitData,setWaitData] = useState<WordRequest[] | null>(null);

const MakeError = (error: PostgrestError) => {
setErrorMessage(`문서 정보 데이터 로드중 오류.\nErrorName: ${error.name ?? "알수없음"}\nError Message: ${error.message ?? "없음"}\nError code: ${error.code}`)
Expand Down Expand Up @@ -123,7 +123,7 @@ export default function AdminHomeWrapper(){
waitQueue.push(r);
});

setWaitDatas(waitQueue);
setWaitData(waitQueue);
updateLoadingState(100, "완료!")
}

Expand All @@ -141,7 +141,7 @@ export default function AdminHomeWrapper(){
return <ErrorPage message={errorMessage}/>
}

if (waitDatas){
return <AdminHome requestDatas={waitDatas} refreshFn={getWaitQueue} />
if (waitData){
return <AdminHome requestData={waitData} refreshFn={getWaitQueue} />
}
}
4 changes: 2 additions & 2 deletions app/api/auth/update_nickname/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ export async function POST(request: NextRequest){
if (!body){
return NextResponse.json({
data: null,
error: "invaild data"
error: "invalid data"
})
}

const {nickname} = body;
if (!nickname){
return NextResponse.json({
data: null,
error: "invaild data"
error: "invalid data"
})
}

Expand Down
59 changes: 59 additions & 0 deletions app/api/words/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@

---

## 엔드포인트
- **URL**: `/api/words/search`
- **Method**: `GET`
- **Description**: 모드별 필터링 및 정렬 옵션을 적용한 단어 리스트 반환

---

## 쿼리 파라미터

### 1. 필수 및 공통 옵션
| 파라미터 | 타입 | 설명 | 기본값 |
| :--- | :--- | :--- | :--- |
| `mode` | `string` | 게임 모드 (`kor-start`, `kor-end`, `kung`, `hunmin`, `jaqi`) | `kor-start` |
| `q` | `string` | **검색어.** 모드에 따라 시작자, 끝자, 또는 초성으로 동작 | - |
| `limit` | `number` | 최대 검색 결과 수 | `100` |
| `sortBy` | `string` | 정렬 기준 (`abc`: 가나다순, `length`: 글자수순, `attack`: 한방단어) | `length` |

### 2. 세부 필터링 (Advanced Options)
| 파라미터 | 타입 | 설명 | 기본값 |
| :--- | :--- | :--- | :--- |
| `manner` | `string` | 단어 필터 (`man`: 매너어, `jen`: 전어, `eti`: 에티켓) | `man` |
| `minLength` | `number` | 최소 글자 수 | `2` |
| `maxLength` | `number` | 최대 글자 수 | `100` |
| `duem` | `boolean` | 두음법칙 적용 여부 (`true`/`false`) | `true` |
| `mission` | `string` | 포함해야 할 특정 글자 (미션 파괴용) | `""` |
| `themeId` | `number` | `jaqi` 모드 사용 시 필수 테마 고유 ID | - |

---

## 예제

### A. 일반적인 끝말잇기 (시작 단어 찾기)
`가`로 시작하는 매너어 50개 검색 (글자수 순 정렬)
```http request
GET /api/words/search?mode=kor-start&q=가&manner=man&limit=50&sortBy=length
```
### B. 쿵쿵따 모드
`나`로 시작하는 3글자 단어 검색 (자동으로 3글자로 설정)
```http request
GET /api/words/search?mode=hunmin&q=ㄱㄴ
```
### C. 훈민정음 (초성 퀴즈)
`ㄱㄴ` 초성을 가진 단어 검색
```http request
GET /api/words/search?mode=hunmin&q=ㄱㄴ
```

## Response Status Code

* 200: OK
* 400: Bad Request
* 필수 파라미터(q) 누락
* 훈민정음 모드에서 2글자가 아닌 쿼리 전송
* 주제어 모드에서 themeId 누락
* 500: Internal Server Error
* 서버 내부 또는 데이터베이스 오류.
97 changes: 97 additions & 0 deletions app/api/words/search/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from 'next/server';
import { SCM } from '@/app/lib/supabaseClient';
import { advancedQueryType } from '@/app/types/type';
import { GameMode } from '@/app/word/search/types';

export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);

const gameMode = (searchParams.get('mode') || 'kor-start') as GameMode;
const searchQuery = searchParams.get('q') || '';
const missionLetter = searchParams.get('mission') || '';
const minimumLength = parseInt(searchParams.get('minLength') || '2');
const maximumLength = parseInt(searchParams.get('maxLength') || '100');
const sortOrder = (searchParams.get('sortBy') || 'length') as 'abc' | 'length' | 'attack';
const isDuemApplied = searchParams.get('duem') !== 'false';
const hasMiniInfo = searchParams.get('miniInfo') === 'true';
const mannerMode = searchParams.get('manner') || 'man';
const isAcceptedOnly = searchParams.get('ingjung') !== 'false';
const displayLimit = parseInt(searchParams.get('limit') || '100');
const themeId = searchParams.get('themeId');

try {
let searchOptions: advancedQueryType;

if (gameMode === 'kor-start' || gameMode === 'kor-end') {
const startLetter = gameMode === 'kor-start' ? searchQuery : searchParams.get('start') || undefined;
const endLetter = gameMode === 'kor-end' ? searchQuery : searchParams.get('end') || undefined;

if (gameMode === 'kor-start' && !startLetter) return handleErrorResponse('시작 초성이 필요합니다.');
if (gameMode === 'kor-end' && !endLetter) return handleErrorResponse('끝 초성이 필요합니다.');

searchOptions = {
mode: gameMode,
start: startLetter?.trim(),
end: endLetter?.trim(),
mission: missionLetter,
ingjung: isAcceptedOnly,
man: mannerMode === 'man',
jen: mannerMode === 'jen',
eti: mannerMode === 'eti',
duem: isDuemApplied,
miniInfo: hasMiniInfo,
length_min: minimumLength,
length_max: maximumLength,
sort_by: sortOrder,
limit: isNaN(displayLimit) ? 100 : displayLimit
};
} else if (gameMode === 'kung') {
if (!searchQuery) return handleErrorResponse('단어가 필요합니다.');
searchOptions = {
mode: 'kung',
start: searchQuery.trim().slice(0, 3),
mission: missionLetter,
ingjung: isAcceptedOnly,
man: mannerMode === 'man',
jen: mannerMode === 'jen',
eti: mannerMode === 'eti',
duem: isDuemApplied,
miniInfo: hasMiniInfo,
length_min: 3,
length_max: 3,
sort_by: sortOrder,
limit: isNaN(displayLimit) ? 100 : displayLimit
};
} else if (gameMode === 'hunmin') {
if (searchQuery.trim().length !== 2) return handleErrorResponse('훈민정음 쿼리는 2글자여야 합니다.');
searchOptions = {
mode: 'hunmin',
query: searchQuery.trim(),
mission: missionLetter,
limit: isNaN(displayLimit) ? 100 : displayLimit
};
} else if (gameMode === 'jaqi') {
if (!themeId) return handleErrorResponse('테마 ID가 필요합니다.');
searchOptions = {
mode: 'jaqi',
query: searchQuery.trim(),
theme: Number(themeId),
limit: isNaN(displayLimit) ? 100 : displayLimit
};
} else {
return handleErrorResponse('유효하지 않은 모드입니다.');
}

const { data, error } = await SCM.get().wordsByAdvancedQuery(searchOptions);

if (error) throw error;

return NextResponse.json(data);
} catch (error) {
return NextResponse.json({ error: error }, { status: 500 });
}
}

function handleErrorResponse(message: string) {
return NextResponse.json({ error: message }, { status: 400 });
}
Loading