From 2da272a26eb4ea8ac6f300354db2dae9aa226e77 Mon Sep 17 00:00:00 2001 From: jtw Date: Wed, 22 Apr 2026 01:40:57 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= =?UTF-8?q?=EC=95=88=EB=90=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/request-docs/RequestDocsHome.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/app/admin/request-docs/RequestDocsHome.tsx b/src/app/admin/request-docs/RequestDocsHome.tsx index 1133186..aec0bb7 100644 --- a/src/app/admin/request-docs/RequestDocsHome.tsx +++ b/src/app/admin/request-docs/RequestDocsHome.tsx @@ -8,6 +8,7 @@ import { Pagination, PaginationContent, PaginationItem, PaginationLink, Paginati import { SCM } from '@/src/app/lib/supabaseClient'; import { PostgrestError } from '@supabase/supabase-js'; import ErrorModal from '@/src/app/components/ErrModal'; +import Link from 'next/link'; type DocsWaitRequest = {id: number, req_at: string, docs_name: string, req_by: string | null, initial_consonant: boolean, req_byId: string | null} @@ -122,10 +123,12 @@ export default function DocsWaitManager({initialData}: {initialData?: DocsWaitRe
- + + +
From 42fbe5466b4fdea3a59c320fbfc09748042b4751 Mon Sep 17 00:00:00 2001 From: jtw Date: Wed, 22 Apr 2026 01:41:45 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20=EC=98=A4=ED=83=88=EC=9E=90=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/admin/request-words/AdminWrapper.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/admin/request-words/AdminWrapper.tsx b/src/app/admin/request-words/AdminWrapper.tsx index 912778d..b7de133 100644 --- a/src/app/admin/request-words/AdminWrapper.tsx +++ b/src/app/admin/request-words/AdminWrapper.tsx @@ -44,7 +44,7 @@ export default function AdminHomeWrapper(){ return; } - updateLoadingState(30, "단어 삭제/추가 요청 단아 가져오는 중..."); + updateLoadingState(30, "단어 삭제/추가 요청 단어 가져오는 중..."); const {data: waitWordsData, error: waitWordsError} = await SCM.get().allWaitWords(); if (waitWordsError){ MakeError(waitWordsError); From a0ec593d0b592f2e5fe0eb67cd3c5ed849cfbd3f Mon Sep 17 00:00:00 2001 From: jtw Date: Wed, 22 Apr 2026 01:48:44 +0900 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20=EB=8B=A8=EC=96=B4=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=EA=B8=B0=20=EB=8B=A8=EC=96=B4=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/word-combiner/WordCombinerClient.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/word-combiner/WordCombinerClient.tsx b/src/app/word-combiner/WordCombinerClient.tsx index d58914d..b70d6b6 100644 --- a/src/app/word-combiner/WordCombinerClient.tsx +++ b/src/app/word-combiner/WordCombinerClient.tsx @@ -90,9 +90,9 @@ export default function WordCombinerClient({ prop }: { prop: WordCombinerWithDat setLoading(true); setTimeout(() => { const manger6 = new CombinationManager(normalJOKAK.replace(/\s+/g, '').split('').sort().join(''), len6WordsData); - setLen6Date(manger6.getBests()); + setLen6Date(manger6.getBests().sort()); const manger5 = new CombinationManager(manger6.remainStr(), len5WordsData); - setLen5Data(manger5.getBests()); + setLen5Data(manger5.getBests().sort()); setLoading(false); setNormalJOKAK(manger5.remainStr()); }, 1) @@ -122,9 +122,9 @@ export default function WordCombinerClient({ prop }: { prop: WordCombinerWithDat setLoading(true); setTimeout(() => { const manger6 = new CombinationManager(highJOKAK.replace(/\s+/g, '').split('').sort().join(''), len6WordsData); - setLen6Date(manger6.getBests()); + setLen6Date(manger6.getBests().sort()); const manger5 = new CombinationManager(manger6.remainStr(), len5WordsData); - setLen5Data(manger5.getBests()); + setLen5Data(manger5.getBests().sort()); setLoading(false); setHighJOKAK(manger5.remainStr()); }, 1) @@ -154,9 +154,9 @@ export default function WordCombinerClient({ prop }: { prop: WordCombinerWithDat setLoading(true); setTimeout(() => { const manger6 = new CombinationManager(rareJOKAK.replace(/\s+/g, '').split('').sort().join(''), len6WordsData); - setLen6Date(manger6.getBests()); + setLen6Date(manger6.getBests().sort()); const manger5 = new CombinationManager(manger6.remainStr(), len5WordsData); - setLen5Data(manger5.getBests()); + setLen5Data(manger5.getBests().sort()); setLoading(false); setRareJOKAK(manger5.remainStr()); }, 1) From 7e32b56bdeae4e1223a81f2f23c83bedb274869c Mon Sep 17 00:00:00 2001 From: jtw Date: Fri, 24 Apr 2026 00:19:34 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=EA=B9=83=ED=97=88=EB=B8=8C=20?= =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=A6=88=20=ED=99=95=EC=9D=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.md | 2 +- src/app/release-note/ReleaseNote.tsx | 185 ++++++++++++++++++++++++--- 2 files changed, 168 insertions(+), 19 deletions(-) diff --git a/docs/README.md b/docs/README.md index 60e3139..87e4dd7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,7 +34,7 @@ | 2 | 📚 **단어장 정리 도구** | 글자순 정렬, 특정 단어 추출 등 단어장 관리 | | 3 | 🗄️ **오픈 DB 단어** | 단어 다운로드 및 검색 | | 4 | 😈 **빌런 단어장 게시** | 빌런 단어장 공유 및 열람 | -| 5 | ⌨️ **타자 연습** | 끄투 단어로 타자 연습 | +| 5 | ⌨️ **타자 연습** | *(예정)* 끄투 단어로 타자 연습 | | 6 | 🔍 **끄코 정보 조회** | 프로필, 랭킹 등 유저 정보 조회 | | 7 | ⚔️ **루트전 엔진** | *(예정)* 루트전 최적 플레이 보조 엔진 | diff --git a/src/app/release-note/ReleaseNote.tsx b/src/app/release-note/ReleaseNote.tsx index ffa4bfa..c0c47c7 100644 --- a/src/app/release-note/ReleaseNote.tsx +++ b/src/app/release-note/ReleaseNote.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react'; import { SCM } from '../lib/supabaseClient'; import ErrorModal from '../components/ErrModal'; import { Calendar, Clock, ChevronRight } from 'lucide-react'; +import MarkdownViewer from '../components/MarkdownViewer'; interface Note { id: number; @@ -12,36 +13,78 @@ interface Note { link: string | null; } +interface GithubRelease { + id: number; + name: string; + body: string; + published_at: string; + html_url: string; + tag_name: string; +} + +type ReleaseTab = 'internal' | 'github'; + const ReleaseNote = () => { const [notes, setNotes] = useState([]); + const [githubNotes, setGithubNotes] = useState([]); const [loading, setLoading] = useState(true); + const [githubError, setGithubError] = useState(null); const [errorModalView, setErrorModalView] = useState(null); - const [expandedNote, setExpandedNote] = useState(null); + const [activeTab, setActiveTab] = useState('internal'); + const [expandedNote, setExpandedNote] = useState(null); useEffect(() => { - const fetchNotes = async () => { + const fetchData = async () => { setLoading(true); - const { data, error } = await SCM.get().releaseNote(); - - if (error) { + + const [internalResult, githubResult] = await Promise.allSettled([ + SCM.get().releaseNote(), + fetch('https://api.github.com/repos/SolidLoop-studio/kkuko-utils/releases', { + headers: { + Accept: 'application/vnd.github+json' + } + }) + ]); + + if (internalResult.status === 'fulfilled') { + const { data, error } = internalResult.value; + if (error) { + setErrorModalView({ + ErrName: error.name, + ErrMessage: error.message, + ErrStackRace: error.code, + inputValue: "릴리즈 노트" + }); + } else { + setNotes(data); + } + } else { setErrorModalView({ - ErrName: error.name, - ErrMessage: error.message, - ErrStackRace: error.code, + ErrName: 'Supabase Error', + ErrMessage: internalResult.reason instanceof Error ? internalResult.reason.message : '릴리즈 노트를 불러오지 못했습니다.', + ErrStackRace: '', inputValue: "릴리즈 노트" }); - setLoading(false); - return; } - - setNotes(data); + + if (githubResult.status === 'fulfilled') { + if (!githubResult.value.ok) { + setGithubError(`GitHub 릴리즈를 불러오지 못했습니다. (HTTP ${githubResult.value.status})`); + } else { + const data = (await githubResult.value.json()) as GithubRelease[]; + setGithubNotes(data); + } + } else { + setGithubError('GitHub 릴리즈를 불러오는 중 오류가 발생했습니다.'); + } + setLoading(false); }; - - fetchNotes(); + + fetchData(); }, []); - const toggleExpand = (id: number) => { + const toggleExpand = (id: string) => { if (expandedNote === id) { setExpandedNote(null); } else { @@ -88,7 +131,38 @@ const ReleaseNote = () => {
- {notes.length === 0 ? ( +
+
+ + +
+
+ + {activeTab === 'internal' && (notes.length === 0 ? (

릴리즈 노트가 없습니다.

@@ -96,7 +170,8 @@ const ReleaseNote = () => {
{notes.map((note) => { const { date, time } = formatDate(note.created_at); - const isExpanded = expandedNote === note.id; + const noteKey = `internal-${note.id}`; + const isExpanded = expandedNote === noteKey; return (
{ >
toggleExpand(note.id)} + onClick={() => toggleExpand(noteKey)} >

@@ -153,6 +228,80 @@ const ReleaseNote = () => { ); })}

+ ))} + + {activeTab === 'github' && ( + githubError ? ( +
+

{githubError}

+
+ ) : githubNotes.length === 0 ? ( +
+

GitHub 릴리즈가 없습니다.

+
+ ) : ( +
+ {githubNotes.map((note) => { + const { date, time } = formatDate(note.published_at); + const noteKey = `github-${note.id}`; + const isExpanded = expandedNote === noteKey; + + return ( +
+
toggleExpand(noteKey)} + > +
+

+ {note.name || note.tag_name} +

+
+
+ + {date} +
+
+ + {time} +
+ + {note.tag_name} + +
+
+ +
+ + {isExpanded && ( + + )} +
+ ); + })} +
+ ) )} {errorModalView && setErrorModalView(null)} />}
From fa6b158dac3c27942dfbc6372f12093c1aaad2a0 Mon Sep 17 00:00:00 2001 From: jtw Date: Fri, 24 Apr 2026 00:28:34 +0900 Subject: [PATCH 5/8] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kkuko/shared/components/TryRenderImg.test.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/__tests__/kkuko/shared/components/TryRenderImg.test.tsx b/src/__tests__/kkuko/shared/components/TryRenderImg.test.tsx index d8b2463..6e6ab4a 100644 --- a/src/__tests__/kkuko/shared/components/TryRenderImg.test.tsx +++ b/src/__tests__/kkuko/shared/components/TryRenderImg.test.tsx @@ -38,7 +38,7 @@ describe('TryRenderImg', () => { render(); const img = screen.getByTestId('next-image'); expect(img).toBeInTheDocument(); - expect(img).toHaveAttribute('src', defaultProps.url); + expect(img).toHaveAttribute('src', expect.stringContaining(defaultProps.url)); expect(img).toHaveAttribute('alt', defaultProps.alt); }); @@ -61,13 +61,13 @@ describe('TryRenderImg', () => { // Expect src to have changed // url + ?r=1&ts=... - const expectedSrc1 = `${defaultProps.url}?r=1&ts=1234567890`; - expect(img).toHaveAttribute('src', expectedSrc1); + const expectedSrc1 = `r=1&ts=1234567890`; + expect(img).toHaveAttribute('src', expect.stringContaining(expectedSrc1)); // Second error -> retry 2 fireEvent.error(img); - const expectedSrc2 = `${defaultProps.url}?r=2&ts=1234567890`; - expect(img).toHaveAttribute('src', expectedSrc2); + const expectedSrc2 = `r=2&ts=1234567890`; + expect(img).toHaveAttribute('src', expect.stringContaining(expectedSrc2)); }); it('should show placeholder and call onFailure after maxRetries exceeded', () => { @@ -112,14 +112,14 @@ describe('TryRenderImg', () => { // Trigger one error to change state fireEvent.error(img); - expect(img).toHaveAttribute('src', expect.stringContaining('?r=1')); + expect(img).toHaveAttribute('src', expect.stringContaining('r=1')); // Change URL const newUrl = 'https://example.com/new.png'; rerender(); const newImg = screen.getByTestId('next-image'); - expect(newImg).toHaveAttribute('src', newUrl); + expect(newImg).toHaveAttribute('src', expect.stringContaining(newUrl)); // Should not have query params yet expect(newImg).not.toHaveAttribute('src', expect.stringContaining('?r=')); }); From 971bba691c26923487488fda28d9f0ccedb85520 Mon Sep 17 00:00:00 2001 From: jtw Date: Fri, 24 Apr 2026 00:55:32 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20=ED=8A=B9=EC=A0=95=20=EA=B8=80?= =?UTF-8?q?=EC=9E=90=EA=B0=80=20=ED=8F=AC=ED=95=A8=EB=90=9C=20=EB=8B=A8?= =?UTF-8?q?=EC=96=B4=20=EC=B6=94=EC=B6=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../manager-tool/extract/ContainX.test.tsx | 102 +++++ src/app/manager-tool/extract/ExtractHome.tsx | 24 +- .../extract/containx/ContainX.tsx | 348 ++++++++++++++++++ .../manager-tool/extract/containx/page.tsx | 26 ++ 4 files changed, 493 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/manager-tool/extract/ContainX.test.tsx create mode 100644 src/app/manager-tool/extract/containx/ContainX.tsx create mode 100644 src/app/manager-tool/extract/containx/page.tsx diff --git a/src/__tests__/manager-tool/extract/ContainX.test.tsx b/src/__tests__/manager-tool/extract/ContainX.test.tsx new file mode 100644 index 0000000..90aa022 --- /dev/null +++ b/src/__tests__/manager-tool/extract/ContainX.test.tsx @@ -0,0 +1,102 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import WordExtractorApp from "@/src/app/manager-tool/extract/containx/ContainX"; +import { getOutsideHelpModal } from "@/test/utils/dom"; + +jest.mock("@/app/manager-tool/extract/components/FileContentDisplay", () => { + return ({ onFileUpload, fileContent, resultData, resultTitle }: any) => ( +
+
File Content: {fileContent || "No content"}
+
Result Title: {resultTitle}
+
Result Count: {resultData?.length || 0}
+ +
{fileContent}
+
+ ); +}); + +describe("ContainX", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("초기 렌더링이 정상적으로 되는지 확인", () => { + render(); + + expect(screen.getByText("X가 포함된 단어 추출")).toBeInTheDocument(); + expect(screen.getAllByText("설정")).toHaveLength(2); + expect(screen.getAllByText("실행")).toHaveLength(2); + }); + + it("포함글자 입력이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const wordIncludeInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("포함글자를 입력하세요"), + ); + expect(wordIncludeInput).toBeDefined(); + + await user.type(wordIncludeInput!, "te"); + expect(wordIncludeInput).toHaveValue("te"); + }); + + it("파일 내용이 없을 때 단어 추출 버튼이 비활성화되는지 확인", () => { + render(); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + expect(extractButton).toBeDisabled(); + }); + + it("파일 업로드 후 포함 단어 추출이 정상적으로 동작하는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + const wordIncludeInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("포함글자를 입력하세요"), + ); + await user.type(wordIncludeInput, "te"); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + await user.click(extractButton); + + await waitFor(() => { + const resultDisplay = screen.getByTestId("file-content-display"); + expect(resultDisplay).toHaveTextContent("Result Count: 2"); + }); + }); + + it("단어 추출 결과에 따라 다운로드 버튼이 활성화되는지 확인", async () => { + const user = userEvent.setup(); + render(); + + const downloadButton = getOutsideHelpModal(() => + screen.getAllByText("결과 다운로드"), + ); + expect(downloadButton).toBeDisabled(); + + const mockUploadButton = screen.getByText("Mock File Upload"); + await user.click(mockUploadButton); + + const wordIncludeInput = getOutsideHelpModal(() => + screen.getAllByPlaceholderText("포함글자를 입력하세요"), + ); + await user.type(wordIncludeInput, "te"); + + const extractButton = getOutsideHelpModal(() => + screen.getAllByText("단어 추출"), + ); + await user.click(extractButton); + + await waitFor(() => { + expect(downloadButton).not.toBeDisabled(); + }); + }); +}); diff --git a/src/app/manager-tool/extract/ExtractHome.tsx b/src/app/manager-tool/extract/ExtractHome.tsx index f934429..7c201cd 100644 --- a/src/app/manager-tool/extract/ExtractHome.tsx +++ b/src/app/manager-tool/extract/ExtractHome.tsx @@ -5,6 +5,7 @@ import { Type, Hash, ArrowRight, + Search, RotateCcw, Merge, FileText, @@ -37,15 +38,24 @@ const ExtractHome: React.FC = () => { }, { id: 3, + name: "X가 포함된 단어 추출", + description: "지정한 글자나 문자열이 포함된 단어를 추출합니다.", + link: "/containx", + icon: Search, + color: "from-cyan-500 to-sky-500", + bgColor: "group-hover:bg-cyan-50 dark:group-hover:bg-cyan-950/20" + }, + { + id: 4, name: "X로 끝나는 단어 추출", - description: "특정 접미사나 끝글자로 마무리되는 단어들을 추출합니다.", + description: "특정 접미사로 마무리되는 단어들을 추출합니다.", link: "/endx", icon: Type, color: "from-purple-500 to-violet-500", bgColor: "group-hover:bg-purple-50 dark:group-hover:bg-purple-950/20" }, { - id: 4, + id: 5, name: "돌림단어 추출", description: "회문이나 대칭 구조를 가진 특별한 단어들을 추출합니다.", link: "/loop", @@ -54,7 +64,7 @@ const ExtractHome: React.FC = () => { bgColor: "group-hover:bg-orange-50 dark:group-hover:bg-orange-950/20" }, { - id: 5, + id: 6, name: "파일 합성", description: "여러 텍스트 파일을 하나로 병합시킵니다.", link: "/merge", @@ -63,7 +73,7 @@ const ExtractHome: React.FC = () => { bgColor: "group-hover:bg-teal-50 dark:group-hover:bg-teal-950/20" }, { - id: 6, + id: 7, name: "한국어 미션단어 추출 - A", description: "한국어 텍스트에서 미션 조건에 맞는 단어들을 추출합니다.", link: "/korean-mission", @@ -72,7 +82,7 @@ const ExtractHome: React.FC = () => { bgColor: "group-hover:bg-pink-50 dark:group-hover:bg-pink-950/20" }, { - id: 7, + id: 8, name: "영어 미션단어 추출", description: "영어 텍스트에서 미션 조건을 만족하는 단어들을 추출합니다.", link: "/english-mission", @@ -81,7 +91,7 @@ const ExtractHome: React.FC = () => { bgColor: "group-hover:bg-indigo-50 dark:group-hover:bg-indigo-950/20" }, { - id: 8, + id: 9, name: "한국어 미션단어 추출 - B", description: "1티어 미션단어만을 선별하여 단어를 추출합니다.", link: "/korean-mission-b", @@ -90,7 +100,7 @@ const ExtractHome: React.FC = () => { bgColor: "group-hover:bg-yellow-50 dark:group-hover:bg-yellow-950/20" }, { - id: 9, + id: 10, name: "기능 제안하기", description: "새로운 기능이 필요하시다면 언제든 요청해주세요!", icon: HelpCircle, diff --git a/src/app/manager-tool/extract/containx/ContainX.tsx b/src/app/manager-tool/extract/containx/ContainX.tsx new file mode 100644 index 0000000..c3a1535 --- /dev/null +++ b/src/app/manager-tool/extract/containx/ContainX.tsx @@ -0,0 +1,348 @@ +"use client"; +import React, { useState } from "react"; +import ErrorModal from "@/src/app/components/ErrModal"; +import type { ErrorMessage } from '@/src/app/types/type' +import Spinner from "@/src/app/components/Spinner"; +import FileContentDisplay from "../components/FileContentDisplay"; +import { Card, CardContent, CardHeader, CardTitle } from "@/src/app/components/ui/card"; +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 { Badge } from "@/src/app/components/ui/badge"; +import { Download, Play, Settings, Zap, Home } from "lucide-react"; +import Link from "next/link"; +import HelpModal from "@/src/app/components/HelpModal"; + +const WordExtractorApp = () => { + const [file, setFile] = useState(null); + const [fileContent, setFileContent] = useState(null); + const [extractedWords, setExtractedWords] = useState([]); + const [sortChecked, setSortChecked] = useState(true); + const [errorModalView, seterrorModalView] = useState(null); + const [loading, setLoading] = useState(false); + const [wordInclude, setWordInclude] = useState(''); + + const handleFileUpload = (content: string) => { + setFileContent(content); + }; + + const handleError = (error: unknown) => { + if (error instanceof Error) { + seterrorModalView({ + ErrName: error.name, + ErrMessage: error.message, + ErrStackRace: error.stack, + inputValue: null + }); + } else { + seterrorModalView({ + ErrName: null, + ErrMessage: null, + ErrStackRace: error as string, + inputValue: null + }); + } + }; + + const extractWords = async () => { + try { + setLoading(true); + await new Promise(resolve => setTimeout(resolve, 1)) + + if (fileContent && wordInclude) { + const uniqueSet = new Set(); + const result: string[] = []; + const words = fileContent.split(/\s+/); + + words.forEach(word => { + const cleanedWord = word.replace(/[.,!?;:()]/g, ''); + if (cleanedWord && cleanedWord.includes(wordInclude) && !uniqueSet.has(cleanedWord)) { + uniqueSet.add(cleanedWord); + result.push(cleanedWord); + } + }); + + setExtractedWords(sortChecked ? result.sort((a, b) => a.localeCompare(b, "ko")) : result); + } + } catch (err) { + handleError(err); + } finally { + setLoading(false) + } + }; + + const downloadExtractedWords = () => { + try { + if (extractedWords.length === 0) return; + const blob = new Blob([extractedWords.join("\n")], { type: "text/plain" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = `extracted_words_${wordInclude}_포함목록.txt`; + link.click(); + } catch (err) { + handleError(err); + } + }; + + return ( +
+
+
+
+
+
+ +
+
+

+ X가 포함된 단어 추출 +

+

+ 텍스트 파일에서 특정 문자열이 포함된 단어들을 추출합니다 +

+
+
+
+ + + + +
+
+
+ 0 +

텍스트 파일을 업로드 합니다.

+
+
+ +
+
+ 1 +

설정

+
+
+

포함할 글자를 입력합니다. (예: "가", "미션")

+
+
+ + +
+ + +
+
+
+
+
+ +
+
+ 2 +

실행

+
+
+

실행 버튼을 누르고 기다립니다.

+
+ +
+
+
+ +
+
+ 3 +

결과 확인 및 다운로드

+
+
+

결과를 확인한 후 다운로드합니다.

+
+ +
+
+
+ +
+

사용 예시

+
+
+

입력:

+
+                                                    미션
+                                                    가을
+                                                    봄날
+                                                    겨울
+                                                    임무
+                                                
+
+
+
+
포함글자: "미" 추출
+
+
+
+
+

추출 결과:

+
+
+
• 미션
+
• 미로
+
+
+ 총 2개 단어 추출됨 +
+
+
+
+
+ +
+

+ 💡 팁: 포함글자에 여러 문자를 입력하면 해당 문자열이 들어간 단어만 추출됩니다. +

+
+
+
+
+
+
+
+ +
+
+
+ +
+ +
+
+ + + + + 설정 + + + +
+ + setWordInclude(e.target.value)} + placeholder="포함글자를 입력하세요" + /> +
+ +
+ setSortChecked(checked as boolean)} + /> + +
+
+
+ + + + + + 실행 + + + + + + + + + + {fileContent && ( + + +
+
+ {fileContent.split('\n').length} +
+
+ 파일의 총 단어 수 +
+
+
+
+ )} +
+
+
+
+ + {errorModalView && ( + seterrorModalView(null)} + error={errorModalView} + /> + )} + + {loading && ( +
+
+ + 처리 중입니다... +
+
+ )} +
+ ); +}; + +export default WordExtractorApp; diff --git a/src/app/manager-tool/extract/containx/page.tsx b/src/app/manager-tool/extract/containx/page.tsx new file mode 100644 index 0000000..b39a208 --- /dev/null +++ b/src/app/manager-tool/extract/containx/page.tsx @@ -0,0 +1,26 @@ +import WordExtractorApp from "./ContainX"; + +export async function generateMetadata() { + return { + title: "끄코 유틸리티 - 단어장 관리", + description: '끄코 유틸리티 - 단어장 관리', + openGraph: { + title: "끄코 유틸리티 - 단어장 관리", + description: "끄코 유틸리티 - 단어장 관리", + type: "website", + url: "https://kkuko-utils.vercel.app/manager-tool/extract/containx", + siteName: "끄코 유틸리티", + locale: "ko_KR", + }, + }; +} + +const ContainXPage: React.FC = () => { + return ( + <> + + + ) +} + +export default ContainXPage; From 8f7df2a436a48944928d1a8e532673604e6c1378 Mon Sep 17 00:00:00 2001 From: jtw Date: Fri, 24 Apr 2026 02:04:43 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20=EC=82=AD=EC=A0=9C=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EC=8B=9C=20=EC=99=84=EB=A3=8C=EB=AA=A8=EB=8B=AC?= =?UTF-8?q?=EC=9D=98=20=ED=85=8D=EC=8A=A4=ED=8A=B8=EA=B0=80=20=EC=9E=98?= =?UTF-8?q?=EB=AA=BB=20=ED=91=9C=EC=8B=9C=EB=90=98=EB=8A=94=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/word/search/[query]/WordInfo.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/word/search/[query]/WordInfo.tsx b/src/app/word/search/[query]/WordInfo.tsx index ddf604f..2a06c4c 100644 --- a/src/app/word/search/[query]/WordInfo.tsx +++ b/src/app/word/search/[query]/WordInfo.tsx @@ -565,7 +565,12 @@ const WordInfo = ({ wordInfo }: { wordInfo: WordInfoProps }) => { {completeModalOpen && 0 ? `추가 요청된 주제: ${completeModalOpen.addThemes.join(", ")}` : ``} ${completeModalOpen.delThemes.length > 0 ? `삭제 요청된 주제: ${completeModalOpen.delThemes.join(", ")}` : ``}` : ``} /> } From 4196528a119fcc67ae9ea9c6a8487b9d4eb06b18 Mon Sep 17 00:00:00 2001 From: jtw Date: Fri, 24 Apr 2026 02:15:35 +0900 Subject: [PATCH 8/8] =?UTF-8?q?ci:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=9E=91=EC=84=B1=20=EC=BD=94=EB=93=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 --- scripts/release.mjs | 43 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/scripts/release.mjs b/scripts/release.mjs index 3aee0d6..9ae2748 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -104,9 +104,13 @@ async function run() { // Repo URL이 있으면 비교 링크 생성 if (config.repoUrl) { - // 릴리즈 모드일 때는 이전 태그와 현재 버전 비교 필요 (구현 생략 - 단순화) - // PR 모드일 때는 현재 버전(old) .. 다음 버전(new) - const prevVersionTag = isReleaseMode ? '...' : `v${currentVersion}`; + // PR 모드일 때는 현재 버전(old) .. 다음 버전(new) + // 릴리즈 모드일 때는 GitHub latest release 태그(old) .. 현재 버전(new) + let prevVersionTag = `v${currentVersion}`; + if (isReleaseMode) { + prevVersionTag = await getPreviousReleaseTag(); + } + header = `# [${versionHeader}](${config.repoUrl}/compare/${prevVersionTag}...${versionHeader}) - ${date}`; } @@ -241,4 +245,37 @@ async function createGitHubRelease(version, body, dryRun) { console.log(`🎉 GitHub Release published: ${release.html_url}`); } +async function getPreviousReleaseTag() { + const token = process.env.GITHUB_TOKEN; + + // GitHub API 우선 시도 (latest release) + if (token) { + try { + const octokit = getOctokit(token); + const { data: latestRelease } = await octokit.rest.repos.getLatestRelease({ + owner: context.repo.owner, + repo: context.repo.repo + }); + + if (latestRelease?.tag_name) { + return latestRelease.tag_name; + } + } catch (error) { + console.warn(`⚠️ Failed to fetch latest release tag from GitHub API: ${error.message}`); + } + } + + // 폴백: 로컬 최신 git 태그 + try { + const tags = await git.tags(); + if (tags.latest) { + return tags.latest; + } + } catch (error) { + console.warn(`⚠️ Failed to fetch latest local git tag: ${error.message}`); + } + + throw new Error('Unable to resolve previous version tag from GitHub latest release or local git tags.'); +} + run();