From 950698ed77630f4ff8dc2f34f04e6aeafc98838b Mon Sep 17 00:00:00 2001 From: JUNG TAEWON <153927840+hafskjfha@users.noreply.github.com> Date: Fri, 31 Oct 2025 12:42:51 +0000 Subject: [PATCH 01/20] =?UTF-8?q?feat:=20=EB=8B=A8=EC=96=B4=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=EA=B8=B0=20=EC=98=81=EC=96=B4=206=EA=B8=80=EC=9E=90?= =?UTF-8?q?=20=EB=8B=A8=EC=96=B4=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/supabase/SupabaseClientManager.ts | 22 +- package-lock.json | 743 +++++++++++++++------- 2 files changed, 523 insertions(+), 242 deletions(-) diff --git a/app/lib/supabase/SupabaseClientManager.ts b/app/lib/supabase/SupabaseClientManager.ts index 786093e..7e4c4a5 100644 --- a/app/lib/supabase/SupabaseClientManager.ts +++ b/app/lib/supabase/SupabaseClientManager.ts @@ -4,9 +4,21 @@ import type { Database } from '@/app/types/database.types'; import type { addWordQueryType, addWordThemeQueryType, DocsLogData, WordLogData } from '@/app/types/type'; import { reverDuemLaw } from '../DuemLaw'; import { sum } from 'es-toolkit'; +import { StorageError } from '@supabase/storage-js'; const CACHE_DURATION = 10 * 60 * 1000; +function storageErrorToPostgresError(storageError: StorageError): PostgrestError { + return { + name: storageError.name ?? "storage_error", + message: storageError.message ?? 'Unknown storage error', + details: "", + hint: "null", + code: '500', + } + +} + class AddManager implements IAddManager { constructor(private readonly supabase: SupabaseClient) { } @@ -47,7 +59,7 @@ class AddManager implements IAddManager { return this.supabase.from('words').upsert(q, { ignoreDuplicates: true, onConflict: "word" }).select('*'); } public async wordsThemes(q: addWordThemeQueryType[]){ - return await this.supabase.from('word_themes').upsert(q, { ignoreDuplicates: true, onConflict: "word_id,theme_id" }).select('words(word),themes(name)') + return await this.supabase.from('word_themes').upsert(q, { ignoreDuplicates: true, onConflict: "word_id,theme_id" }).select('words(word),themes(name)') } public async wordThemesReq(q: { word_id: number; theme_id: number; typez: 'add' | 'delete'; req_by: string | null; }[]) { return await this.supabase.from('word_themes_wait').upsert(q, { onConflict: "word_id,theme_id", ignoreDuplicates: true }).select('themes(name), typez'); @@ -208,11 +220,15 @@ class GetManager implements IGetManager { // 단어조합기 전용 if (lenf){ const {data:wordsData, error: wordsError} = await this.supabase.from('words').select('word, noin_canuse, k_canuse').in('length', [5, 6]); + const { data: engData, error: engError } = await this.supabase.storage.from('public_img').download('txt/eng_len_6_words.txt') if (wordsError) return {data: null, error: wordsError} - + if (engError) return {data: null, error: storageErrorToPostgresError(engError)} + + const engText = await engData.text(); const now = Date.now(); const data = [ - ...wordsData.map(({word,noin_canuse,k_canuse})=>({word,noin_canuse,k_canuse,status: "ok" as const})) + ...wordsData.map(({word,noin_canuse,k_canuse})=>({word,noin_canuse,k_canuse,status: "ok" as const})), + ...engText.split(/\r?\n/).map(word => ({word: word.trim(), noin_canuse: false, k_canuse: true, status: "ok" as const})) ] this.wordsCache[key] = { data, diff --git a/package-lock.json b/package-lock.json index b8939a2..211ffb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2752,6 +2752,15 @@ "node": ">=18" } }, + "node_modules/@emnapi/runtime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.6.0.tgz", + "integrity": "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", @@ -2989,14 +2998,169 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", + "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", + "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", + "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", + "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", + "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", + "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", + "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", "cpu": [ "x64" ], - "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", + "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", + "cpu": [ + "arm64" + ], "optional": true, "os": [ "linux" @@ -3006,13 +3170,12 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", + "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", "cpu": [ "x64" ], - "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -3021,14 +3184,97 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", + "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", + "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", + "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", + "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.3" + } + }, "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", + "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", "cpu": [ "x64" ], - "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -3040,17 +3286,37 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" + "@img/sharp-libvips-linux-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", + "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", + "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", "cpu": [ "x64" ], - "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -3062,7 +3328,79 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + "@img/sharp-libvips-linuxmusl-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", + "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.5.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", + "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", + "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", + "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@isaacs/cliui": { @@ -3708,10 +4046,9 @@ "license": "MIT" }, "node_modules/@next/env": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.4.tgz", - "integrity": "sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==", - "license": "MIT" + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.6.tgz", + "integrity": "sha512-3qBGRW+sCGzgbpc5TS1a0p7eNxnOarGVQhZxfvTdnV0gFI61lX7QNtQ4V1TSREctXzYn5NetbUsLvyqwLFJM6Q==" }, "node_modules/@next/eslint-plugin-next": { "version": "15.1.0", @@ -3723,14 +4060,73 @@ "fast-glob": "3.3.1" } }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.6.tgz", + "integrity": "sha512-ES3nRz7N+L5Umz4KoGfZ4XX6gwHplwPhioVRc25+QNsDa7RtUF/z8wJcbuQ2Tffm5RZwuN2A063eapoJ1u4nPg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.6.tgz", + "integrity": "sha512-JIGcytAyk9LQp2/nuVZPAtj8uaJ/zZhsKOASTjxDug0SPU9LAM3wy6nPU735M1OqacR4U20LHVF5v5Wnl9ptTA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.6.tgz", + "integrity": "sha512-qvz4SVKQ0P3/Im9zcS2RmfFL/UCQnsJKJwQSkissbngnB/12c6bZTCB0gHTexz1s6d/mD0+egPKXAIRFVS7hQg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.6.tgz", + "integrity": "sha512-FsbGVw3SJz1hZlvnWD+T6GFgV9/NYDeLTNQB2MXoPN5u9VA9OEDy6fJEfePfsUKAhJufFbZLgp0cPxMuV6SV0w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.4.tgz", - "integrity": "sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw==", + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.6.tgz", + "integrity": "sha512-3QnHGFWlnvAgyxFxt2Ny8PTpXtQD7kVEeaFat5oPAHHI192WKYB+VIKZijtHLGdBBvc16tiAkPTDmQNOQ0dyrA==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -3740,13 +4136,12 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.4.tgz", - "integrity": "sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw==", + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.6.tgz", + "integrity": "sha512-OsGX148sL+TqMK9YFaPFPoIaJKbFJJxFzkXZljIgA9hjMjdruKht6xDCEv1HLtlLNfkx3c5w2GLKhj7veBQizQ==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -3755,6 +4150,36 @@ "node": ">= 10" } }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.6.tgz", + "integrity": "sha512-ONOMrqWxdzXDJNh2n60H6gGyKed42Ieu6UTVPZteXpuKbLZTH4G4eBMsr5qWgOBA+s7F+uB4OJbZnrkEDnZ5Fg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.6.tgz", + "integrity": "sha512-pxK4VIjFRx1MY92UycLOOw7dTdvccWsNETQ0kDHkBlcFH1GrTLUjSiHU1ohrznnux6TqRHgv5oflhfIWZwVROQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -6460,12 +6885,6 @@ "@supabase/storage-js": "2.7.1" } }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "license": "Apache-2.0" - }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -7811,13 +8230,12 @@ } }, "node_modules/axios": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", - "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", - "license": "MIT", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.1.tgz", + "integrity": "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw==", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -8109,17 +8527,6 @@ "dev": true, "license": "MIT" }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -8695,20 +9102,6 @@ "dev": true, "license": "MIT" }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "optional": true, - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -8727,17 +9120,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "optional": true, - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -9477,10 +9859,9 @@ } }, "node_modules/detect-libc": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", - "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", - "license": "Apache-2.0", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "optional": true, "engines": { "node": ">=8" @@ -11901,13 +12282,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "license": "MIT", - "optional": true - }, "node_modules/is-async-function": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", @@ -14932,15 +15306,12 @@ "dev": true }, "node_modules/next": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/next/-/next-15.2.4.tgz", - "integrity": "sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==", - "license": "MIT", + "version": "15.5.6", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.6.tgz", + "integrity": "sha512-zTxsnI3LQo3c9HSdSf91O1jMNsEzIXDShXd4wVdg9y5shwLqBXi4ZtUUJyB86KGVSJLZx0PFONvO54aheGX8QQ==", "dependencies": { - "@next/env": "15.2.4", - "@swc/counter": "0.1.3", + "@next/env": "15.5.6", "@swc/helpers": "0.5.15", - "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -14952,19 +15323,19 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.2.4", - "@next/swc-darwin-x64": "15.2.4", - "@next/swc-linux-arm64-gnu": "15.2.4", - "@next/swc-linux-arm64-musl": "15.2.4", - "@next/swc-linux-x64-gnu": "15.2.4", - "@next/swc-linux-x64-musl": "15.2.4", - "@next/swc-win32-arm64-msvc": "15.2.4", - "@next/swc-win32-x64-msvc": "15.2.4", - "sharp": "^0.33.5" + "@next/swc-darwin-arm64": "15.5.6", + "@next/swc-darwin-x64": "15.5.6", + "@next/swc-linux-arm64-gnu": "15.5.6", + "@next/swc-linux-arm64-musl": "15.5.6", + "@next/swc-linux-x64-gnu": "15.5.6", + "@next/swc-linux-x64-musl": "15.5.6", + "@next/swc-win32-arm64-msvc": "15.5.6", + "@next/swc-win32-x64-msvc": "15.5.6", + "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.41.2", + "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", @@ -19924,16 +20295,15 @@ } }, "node_modules/sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", + "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", "hasInstallScript": true, - "license": "Apache-2.0", "optional": true, "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.0", + "semver": "^7.7.2" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -19942,25 +20312,28 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" + "@img/sharp-darwin-arm64": "0.34.4", + "@img/sharp-darwin-x64": "0.34.4", + "@img/sharp-libvips-darwin-arm64": "1.2.3", + "@img/sharp-libvips-darwin-x64": "1.2.3", + "@img/sharp-libvips-linux-arm": "1.2.3", + "@img/sharp-libvips-linux-arm64": "1.2.3", + "@img/sharp-libvips-linux-ppc64": "1.2.3", + "@img/sharp-libvips-linux-s390x": "1.2.3", + "@img/sharp-libvips-linux-x64": "1.2.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", + "@img/sharp-libvips-linuxmusl-x64": "1.2.3", + "@img/sharp-linux-arm": "0.34.4", + "@img/sharp-linux-arm64": "0.34.4", + "@img/sharp-linux-ppc64": "0.34.4", + "@img/sharp-linux-s390x": "0.34.4", + "@img/sharp-linux-x64": "0.34.4", + "@img/sharp-linuxmusl-arm64": "0.34.4", + "@img/sharp-linuxmusl-x64": "0.34.4", + "@img/sharp-wasm32": "0.34.4", + "@img/sharp-win32-arm64": "0.34.4", + "@img/sharp-win32-ia32": "0.34.4", + "@img/sharp-win32-x64": "0.34.4" } }, "node_modules/shebang-command": { @@ -20169,16 +20542,6 @@ "node": ">=4" } }, - "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "license": "MIT", - "optional": true, - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, "node_modules/skin-tone": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", @@ -20334,14 +20697,6 @@ "readable-stream": "^2.0.2" } }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -22376,96 +22731,6 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.4.tgz", - "integrity": "sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.4.tgz", - "integrity": "sha512-3qK2zb5EwCwxnO2HeO+TRqCubeI/NgCe+kL5dTJlPldV/uwCnUgC7VbEzgmxbfrkbjehL4H9BPztWOEtsoMwew==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.4.tgz", - "integrity": "sha512-HFN6GKUcrTWvem8AZN7tT95zPb0GUGv9v0d0iyuTb303vbXkkbHDp/DxufB04jNVD+IN9yHy7y/6Mqq0h0YVaQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.4.tgz", - "integrity": "sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.4.tgz", - "integrity": "sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.4.tgz", - "integrity": "sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } } } } From 0e7cadb39d41fd1ef657f1250dfb23ff8e898569 Mon Sep 17 00:00:00 2001 From: JUNG TAEWON <153927840+hafskjfha@users.noreply.github.com> Date: Fri, 31 Oct 2025 12:43:27 +0000 Subject: [PATCH 02/20] =?UTF-8?q?fix:=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/word/words-download/WordsDownloadHome.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/word/words-download/WordsDownloadHome.tsx b/app/word/words-download/WordsDownloadHome.tsx index 14f9792..da057e1 100644 --- a/app/word/words-download/WordsDownloadHome.tsx +++ b/app/word/words-download/WordsDownloadHome.tsx @@ -462,7 +462,7 @@ export default function KoreanWordStats() { {selectedCategory === 'wordNotChain' && '끝말잇기 사용불가 단어란?'}

- {selectedCategory === 'acknowledged' && '끄코 특수규칙인 "아인정"을 켜야지 사용할 수 있는 단어입니다. 단어부에 의해 삭제/추가가 일어납니다.'} + {selectedCategory === 'acknowledged' && '끄코 특수규칙인 "어인정"을 켜야지 사용할 수 있는 단어입니다. 단어부에 의해 삭제/추가가 일어납니다.'} {selectedCategory === 'notAcknowledged' && '끄코에서 "어인정"여부에 상관없이 사용 가능한 단어입니다. 단어 추가/삭제가 잘 일어 나지 않습니다.'} {selectedCategory === 'added' && '사용자들이 DB에 추가를 요청한 단어들입니다. 검토 후 DB에 추가될 수 있습니다.'} {selectedCategory === 'deleted' && '사용자들이 DB에서 삭제를 요청한 단어들입니다. 검토 후 DB에서 제거될 수 있습니다.'} From e1323d637d2d0a1384e63cec2c754447fb93f9da Mon Sep 17 00:00:00 2001 From: JUNG TAEWON <153927840+hafskjfha@users.noreply.github.com> Date: Wed, 19 Nov 2025 09:37:23 +0000 Subject: [PATCH 03/20] =?UTF-8?q?feat:=20=EB=AF=B8=EC=85=98=EB=8B=A8?= =?UTF-8?q?=EC=96=B4=20=EC=B6=94=EC=B6=9CB=20=EB=8F=99=EB=A5=A0=20?= =?UTF-8?q?=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/collections.ts | 21 +++- .../korean-mission-b/KoreanMissionB.tsx | 111 +++++++++++------- 2 files changed, 85 insertions(+), 47 deletions(-) diff --git a/app/lib/collections.ts b/app/lib/collections.ts index 349a533..76b0566 100644 --- a/app/lib/collections.ts +++ b/app/lib/collections.ts @@ -38,16 +38,25 @@ class DefaultDict { } /** - * 키를 기준으로 정렬 + * 키를 기준으로 정렬하거나, 사용자 정의 비교함수 사용 * - * @returns 전체 키/값 쌍 반환 + * @param compareFn (optional) 정렬에 사용할 비교 함수 */ - sortedEntries(): [K, V][] { - return [...this.store.entries()].sort(([a], [b]) => { + sortedEntries( + compareFn?: (a: [K, V], b: [K, V]) => number + ): [K, V][] { + const entries = [...this.store.entries()]; + + if (compareFn) { + return entries.sort(compareFn); + } + + // 기본 정렬 로직 + return entries.sort(([a], [b]) => { if (typeof a === "string" && typeof b === "string") { - return a.localeCompare(b,"ko-KR"); // 문자열 비교 + return a.localeCompare(b, "ko-KR"); } - return a > b ? 1 : a < b ? -1 : 0; // 일반적인 비교 연산자 + return a > b ? 1 : a < b ? -1 : 0; }); } diff --git a/app/manager-tool/extract/korean-mission-b/KoreanMissionB.tsx b/app/manager-tool/extract/korean-mission-b/KoreanMissionB.tsx index 11d9175..ad30ca5 100644 --- a/app/manager-tool/extract/korean-mission-b/KoreanMissionB.tsx +++ b/app/manager-tool/extract/korean-mission-b/KoreanMissionB.tsx @@ -13,10 +13,19 @@ import { Download, Play, Settings, Zap, Home } from "lucide-react"; import { DefaultDict } from "@/app/lib/collections"; import Link from "next/link"; import HelpModal from "@/app/components/HelpModal"; +import { reverDuemLaw } from "@/app/lib/DuemLaw"; + +const MISSION_LETTERS = "가나다라마바사아자차카타파하"; + +interface MissionWordEntry { + count: number; + len: number; + words: string[]; +} const f = (word: string) => { let r = `${word} `; - for (const m of "가나다라마바사아자차카타파하") { + for (const m of MISSION_LETTERS) { const pp = (word.match(new RegExp(m, "gi")) || []).length if (pp >= 1) { r += `[${m}${pp}]`; @@ -25,6 +34,13 @@ const f = (word: string) => { return r; } +function count(text: string, target: string): number { + const escapedTarget = target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(escapedTarget, 'g'); + const matches = text.match(regex); + return matches ? matches.length : 0; +} + const WordExtractorApp = () => { const [file, setFile] = useState(null); const [fileContent, setFileContent] = useState(null); @@ -64,52 +80,65 @@ const WordExtractorApp = () => { await new Promise(resolve => setTimeout(resolve, 1)) if (fileContent) { const words = fileContent.split(/\s+/); - // {시작글자: 해당단어들 리스트} - const kkk = new DefaultDict(() => []); const result: string[] = []; - for (const word of words) { - kkk.get(word[0]).push(word) - } - // 정렬하여 dict추출 - const ppp = kkk.sortedEntries(); - // [시작글자, 단어들 리스트] - for (const [l, v] of ppp) { - // ww: 1티어 단어, co: 1티어 단어의 미션 글자 수 - let ww: string | undefined = undefined; - let co: number = 0; + const missionWordsMap = new DefaultDict>(() => + new DefaultDict(() => ({ count: 0, len: 0, words: [] })) + ); + + for (const word of new Set(words)){ + if (!word.trim()) continue; + const firstChar = word[0]; + for (const m of MISSION_LETTERS){ + const missionCount = count(word, m); + if (missionCount === 0) continue; + const k = missionWordsMap.get(firstChar).get(m); + if (k.count === missionCount) { + if (k.len < word.length) { + k.len = word.length; + k.words = [word]; + } else if (k.len === word.length){ + k.words.push(word); + } + + } else if (k.count < missionCount) { + k.count = missionCount; + k.len = word.length; + k.words = [word]; + } - // 미션 단어수 체크 - for (const m of "가나다라마바사아자차카타파하") { - for (const word of v) { - const pp = (word.match(new RegExp(m, "gi")) || []).length; - if (pp > 0) { - if (ww === undefined) { - // 초기화 - ww = word; - co = pp; - } - else { - // 현재 1티어 미션 글자수 보다 미션글자수가 크거나 미션글자수가 같아도 길이가 길면 갱신 - if (co === pp && ww.length < word.length) ww = word; - else if (pp > co) { - ww = word; - co = pp; - } + for (const duemFirstChar of reverDuemLaw(firstChar)){ + if (duemFirstChar === firstChar) continue; + const duemK = missionWordsMap.get(duemFirstChar).get(m); + if (duemK.count === missionCount) { + if (duemK.len < word.length) { + duemK.len = word.length; + duemK.words = [word]; + } else if (duemK.len === word.length){ + duemK.words.push(word); } + + } else if (duemK.count < missionCount) { + duemK.count = missionCount; + duemK.len = word.length; + duemK.words = [word]; } } - // 1티어 단어 존재한다면 - if (ww !== undefined) { - // 결과 저장 - if (!result.includes(`=[${l}]=`)) result.push(`=[${l}]=`); - result.push(`-${m}-`); - if (showMissionLetter) result.push(f(ww)); - else result.push(ww); - result.push(""); - // 초기화 - ww = undefined; - co = 0; + } + } + + for (const [startChar, missionMap] of missionWordsMap.sortedEntries()){ + result.push(`=[${startChar}]=`) + for (const m of MISSION_LETTERS){ + if (missionMap.get(m).words.length === 0) continue; + result.push(`-${m}-`); + for (const w of missionMap.get(m).words.sort((a,b) => a.localeCompare(b, "ko-KR"))){ + if (showMissionLetter){ + result.push(f(w)); + } else { + result.push(w); + } } + result.push(``); } } setExtractedWords(result); From 3ec688fea36b53cf52d09dc165e21b6456796a67 Mon Sep 17 00:00:00 2001 From: JUNG TAEWON <153927840+hafskjfha@users.noreply.github.com> Date: Wed, 19 Nov 2025 09:47:16 +0000 Subject: [PATCH 04/20] =?UTF-8?q?feat:=20=EC=98=81=EC=96=B4=20=EB=8C=80?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=20->=20=EC=86=8C=EB=AC=B8=EC=9E=90=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/manager-tool/arrange/ArrangeHome.tsx | 40 +++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/app/manager-tool/arrange/ArrangeHome.tsx b/app/manager-tool/arrange/ArrangeHome.tsx index 1cf5b99..0369c00 100644 --- a/app/manager-tool/arrange/ArrangeHome.tsx +++ b/app/manager-tool/arrange/ArrangeHome.tsx @@ -578,8 +578,34 @@ const ToolSector = ({ fileContent, setFileContent, setLineCount, seterrorModalVi } }; + const handleConvertToLowercase = () => { + try { + const updatedContent = fileContent.toLowerCase(); + if (updatedContent === fileContent) return; + pushToUndoStack(fileContent); + setFileContent(updatedContent); + setLineCount(updatedContent.split("\n").length); + } catch (err) { + if (err instanceof Error) { + seterrorModalView({ + ErrName: err.name, + ErrMessage: err.message, + ErrStackRace: err.stack, + inputValue: `ConvertToLowercase | ${fileContent}` + }); + } else { + seterrorModalView({ + ErrName: null, + ErrMessage: null, + ErrStackRace: err as string, + inputValue: `ConvertToLowercase | ${fileContent}` + }); + } + } + }; + return ( -

+
{/* 헤더 */}

@@ -619,6 +645,7 @@ const ToolSector = ({ fileContent, setFileContent, setLineCount, seterrorModalVi
  • 중복 제거: 중복된 단어들을 삭제합니다.
  • 빈 줄 제거: 빈줄을 삭제합니다.
  • 공백 → 줄바꿈: 공백을 줄바꿈으로 바꿉니다. 이 웹사이트의 대부분 내용들은 줄바꿈을 한 단어로 인식합니다.
  • +
  • 영어 대 → 소: 영어 대문자를 소문자로 변환합니다.
  • @@ -865,6 +892,17 @@ const ToolSector = ({ fileContent, setFileContent, setLineCount, seterrorModalVi
    + {/* 영어 대문자 -> 소문자 */} +
    + 영어 대 → 소: + +
    From 92e358bf07e6873457b87d78a2ec7450aae3c870 Mon Sep 17 00:00:00 2001 From: JUNG TAEWON <153927840+hafskjfha@users.noreply.github.com> Date: Mon, 24 Nov 2025 03:03:48 +0000 Subject: [PATCH 05/20] =?UTF-8?q?feat:=20=EC=B6=94=EA=B0=80=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EC=B2=98=EB=A6=AC=EC=9D=98=20=EC=A3=BC=EC=A0=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=EB=A1=9C=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=EA=B0=80=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/request-words/AdminRequestHome.tsx | 132 +++++++++-- app/admin/request-words/ThemeSelectModal.tsx | 221 +++++++++++++++++++ 2 files changed, 331 insertions(+), 22 deletions(-) create mode 100644 app/admin/request-words/ThemeSelectModal.tsx diff --git a/app/admin/request-words/AdminRequestHome.tsx b/app/admin/request-words/AdminRequestHome.tsx index 4ac07d1..6eca9b9 100644 --- a/app/admin/request-words/AdminRequestHome.tsx +++ b/app/admin/request-words/AdminRequestHome.tsx @@ -38,6 +38,7 @@ import { isNoin } from '@/app/lib/lib' import { addWordQueryType } from '@/app/types/type' import Link from 'next/link' import { ArrowLeft } from 'lucide-react' +import ThemeSelectModal from './ThemeSelectModal' // 타입 정의 type Theme = { @@ -65,10 +66,24 @@ export default function AdminHome({ requestDatas, refreshFn }: { requestDatas: W const [currentPage, setCurrentPage] = useState(1); const [allSelected, setAllSelected] = useState(false); const [errorModalView, setErrorModalView] = useState(null); + const [themeModalOpen, setThemeModalOpen] = useState(false); + const [selectedRequestForModal, setSelectedRequestForModal] = useState(null); + const [allThemes, setAllThemes] = useState<{ id: number; name: string; code: string }[]>([]); const user = useSelector((state: RootState) => state.user); const PAGE_SIZE = 30; + // 전체 주제 목록 불러오기 + useEffect(() => { + const loadAllThemes = async () => { + const { data, error } = await SCM.get().allThemes(); + if (!error && data) { + setAllThemes(data); + } + }; + loadAllThemes(); + }, []); + // 요청 타입별 필터링 const filteredRequests = requestDatas.filter(request => { if (selectedTab === "all") return true; @@ -108,30 +123,32 @@ export default function AdminHome({ requestDatas, refreshFn }: { requestDatas: W setSelectedRequests(newSelected); }; - // 주제 선택 토글 - const toggleTheme = (requestId: number, themeId: number) => { - const currentThemes = selectedThemes[requestId] || new Set(); + // 주제 선택 버튼 클릭 핸들러 + const handleThemeSelectClick = (request: WordRequest) => { + setSelectedRequestForModal(request); + setThemeModalOpen(true); + }; + + // 모달에서 주제 선택 확인 핸들러 + const handleThemeModalConfirm = (selectedThemesList: Theme[]) => { + if (!selectedRequestForModal) return; + const newSelectedThemes = { ...selectedThemes }; + const themeIds = new Set(selectedThemesList.map(t => t.theme_id)); + newSelectedThemes[selectedRequestForModal.id] = themeIds; + setSelectedThemes(newSelectedThemes); - if (currentThemes.has(themeId)) { - currentThemes.delete(themeId); - if (currentThemes.size === 0) { - toggleRequest(requestId) - } - } else { - currentThemes.add(themeId); + // 주제가 선택되면 해당 요청도 자동으로 선택 + if (themeIds.size > 0) { const newSelected = new Set(selectedRequests); - if (!newSelected.has(requestId)) { - newSelected.add(requestId); + if (!newSelected.has(selectedRequestForModal.id)) { + newSelected.add(selectedRequestForModal.id); if (newSelected.size === currentRequests.length) { setAllSelected(true); } setSelectedRequests(newSelected); } } - - newSelectedThemes[requestId] = currentThemes; - setSelectedThemes(newSelectedThemes); }; const makeError = (error: PostgrestError) => { @@ -155,11 +172,18 @@ export default function AdminHome({ requestDatas, refreshFn }: { requestDatas: W const request = requestDatas.find(r => r.id === reqId); const selectedThemeIds = selectedThemes[reqId] || new Set(); + // allThemes에서 선택된 주제 정보 가져오기 + const selectedThemeObjects = allThemes + .filter(theme => selectedThemeIds.has(theme.id)) + .map(theme => ({ + theme_id: theme.id, + theme_name: theme.name, + theme_code: theme.code + })); + return { ...request, - selectedThemes: request?.wait_themes?.filter(theme => - selectedThemeIds.has(theme.theme_id) - ) + selectedThemes: selectedThemeObjects }; }); @@ -173,6 +197,8 @@ export default function AdminHome({ requestDatas, refreshFn }: { requestDatas: W // 승인할 목록에서 쿼리에 맞게 배분 for (const req of requestsToApprove) { + const selectedThemeIds = selectedThemes[req.id!] || new Set(); + switch (req.request_type) { case "add": if (!req.word || !req.selectedThemes || req.selectedThemes.length === 0) continue; @@ -188,10 +214,16 @@ export default function AdminHome({ requestDatas, refreshFn }: { requestDatas: W continue case "theme_change": - if (!req.word_id || !req.selectedThemes) continue; + if (!req.word_id) continue; const addT: { word_id: number, theme_id: number }[] = []; const delT: { word_id: number, theme_id: number }[] = []; - req.selectedThemes.forEach((theme) => { + + // theme_change는 wait_themes를 직접 사용 + const themesToProcess = req.wait_themes?.filter(theme => + selectedThemeIds.has(theme.theme_id) + ) || []; + + themesToProcess.forEach((theme) => { if (theme.typez === "add") { addT.push({ word_id: req.word_id as number, theme_id: theme.theme_id }) } @@ -619,14 +651,57 @@ export default function AdminHome({ requestDatas, refreshFn }: { requestDatas: W {renderRequestTypeBadge(request.request_type)} - {request.wait_themes ? ( + {request.request_type === 'add' ? ( +
    + + {selectedThemes[request.id] && selectedThemes[request.id].size > 0 && ( +
    + {allThemes + .filter(theme => selectedThemes[request.id]?.has(theme.id)) + .map((theme, index) => ( + + {theme.name} + + ))} +
    + )} +
    + ) : request.wait_themes ? (
    {request.wait_themes.map((theme, index) => (
    toggleTheme(request.id, theme.theme_id)} + onCheckedChange={() => { + const currentThemes = selectedThemes[request.id] || new Set(); + const newSelectedThemes = { ...selectedThemes }; + if (currentThemes.has(theme.theme_id)) { + currentThemes.delete(theme.theme_id); + if (currentThemes.size === 0) { + toggleRequest(request.id) + } + } else { + currentThemes.add(theme.theme_id); + const newSelected = new Set(selectedRequests); + if (!newSelected.has(request.id)) { + newSelected.add(request.id); + if (newSelected.size === currentRequests.length) { + setAllSelected(true); + } + setSelectedRequests(newSelected); + } + } + newSelectedThemes[request.id] = currentThemes; + setSelectedThemes(newSelectedThemes); + }} />
    ) diff --git a/app/admin/request-words/ThemeSelectModal.tsx b/app/admin/request-words/ThemeSelectModal.tsx new file mode 100644 index 0000000..44170a4 --- /dev/null +++ b/app/admin/request-words/ThemeSelectModal.tsx @@ -0,0 +1,221 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/app/components/ui/dialog" +import { Button } from "@/app/components/ui/button" +import { Checkbox } from "@/app/components/ui/checkbox" +import { Badge } from "@/app/components/ui/badge" +import { ScrollArea } from "@/app/components/ui/scroll-area" +import { SCM } from '@/app/lib/supabaseClient' + +type Theme = { + theme_id: number; + theme_name: string; + theme_code: string; +} + +type AllTheme = { + id: number; + name: string; + code: string; +} + +type ThemeSelectModalProps = { + isOpen: boolean; + onClose: () => void; + word: string; + initialSelectedThemes: Theme[]; + initialSelectedThemeIds?: Set; + onConfirm: (selectedThemes: Theme[]) => void; +} + +export default function ThemeSelectModal({ + isOpen, + onClose, + word, + initialSelectedThemes, + initialSelectedThemeIds, + onConfirm +}: ThemeSelectModalProps) { + const [allThemes, setAllThemes] = useState([]); + const [selectedThemes, setSelectedThemes] = useState>(new Set()); + const [loading, setLoading] = useState(false); + + // 모달이 열릴 때 주제 목록 가져오기 + useEffect(() => { + if (isOpen) { + loadThemes(); + // 초기 선택된 주제 설정 - 요청으로 들어온 주제 + 이미 선택한 주제 + const requestThemeIds = new Set(initialSelectedThemes.map(t => t.theme_id)); + const previouslySelectedIds = initialSelectedThemeIds || new Set(); + const combinedIds = new Set([...requestThemeIds, ...previouslySelectedIds]); + setSelectedThemes(combinedIds); + } + }, [isOpen, initialSelectedThemes, initialSelectedThemeIds]); + + const loadThemes = async () => { + setLoading(true); + const { data, error } = await SCM.get().allThemes(); + if (!error && data) { + setAllThemes(data); + } + setLoading(false); + }; + + // code가 숫자로만 이루어진지 확인 + const isNumericCode = (code: string) => /^\d+$/.test(code); + + // A그룹: code가 숫자인 것 + const groupA = allThemes.filter(theme => isNumericCode(theme.code)); + // B그룹: A그룹에 포함되지 않는 것 + const groupB = allThemes.filter(theme => !isNumericCode(theme.code)); + + const toggleTheme = (themeId: number) => { + const newSelected = new Set(selectedThemes); + if (newSelected.has(themeId)) { + newSelected.delete(themeId); + } else { + newSelected.add(themeId); + } + setSelectedThemes(newSelected); + }; + + const handleConfirm = () => { + const selectedThemeObjects: Theme[] = allThemes + .filter(theme => selectedThemes.has(theme.id)) + .map(theme => ({ + theme_id: theme.id, + theme_name: theme.name, + theme_code: theme.code + })); + onConfirm(selectedThemeObjects); + onClose(); + }; + + // 선택된 주제 정보 가져오기 + const getSelectedThemeNames = () => { + return allThemes + .filter(theme => selectedThemes.has(theme.id)) + .map(theme => theme.name); + }; + + return ( + + + + 주제 선택 - {word} + + 이 단어에 추가할 주제를 선택하세요. + + + +
    + {/* 선택된 주제 표시 */} +
    +

    + 선택된 주제 ({selectedThemes.size}개) +

    +
    + {selectedThemes.size === 0 ? ( + + 선택된 주제가 없습니다. + + ) : ( + getSelectedThemeNames().map((name, index) => ( + + {name} + + )) + )} +
    +
    + + {/* 주제 선택 목록 */} + + {loading ? ( +
    + 주제 목록을 불러오는 중... +
    + ) : ( +
    + {/* A그룹: 숫자 코드 */} + {groupA.length > 0 && ( +
    +

    + 일반 주제 +

    +
    + {groupA.map((theme) => ( +
    + toggleTheme(theme.id)} + /> + +
    + ))} +
    +
    + )} + + {/* B그룹: 특수 주제 */} + {groupB.length > 0 && ( +
    +

    + 특수 주제 +

    +
    + {groupB.map((theme) => ( +
    + toggleTheme(theme.id)} + /> + +
    + ))} +
    +
    + )} +
    + )} +
    +
    + + + + + +
    +
    + ); +} From e7a57ce6b6c011c935cd5525c97e2d496703afe2 Mon Sep 17 00:00:00 2001 From: JUNG TAEWON <153927840+hafskjfha@users.noreply.github.com> Date: Mon, 24 Nov 2025 03:18:54 +0000 Subject: [PATCH 06/20] =?UTF-8?q?fix:=20=EB=8B=A8=EC=96=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=EC=9A=94=EC=B2=AD=EC=8B=9C=20=EC=A3=BC=EC=A0=9C=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=ED=95=84=EC=88=98=20=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/word/adds/WordsAddHome.tsx | 11 +++++++++-- app/word/components/WordAddFrom.tsx | 2 +- app/word/search/[query]/WordInfoPage.tsx | 6 +++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/app/word/adds/WordsAddHome.tsx b/app/word/adds/WordsAddHome.tsx index bdf6496..2cd9f2e 100644 --- a/app/word/adds/WordsAddHome.tsx +++ b/app/word/adds/WordsAddHome.tsx @@ -165,7 +165,14 @@ export default function WordsAddPage() { if (!trimmedLine) return; const delimiterIndex = trimmedLine.indexOf('/'); - if (delimiterIndex === -1) return; + if (delimiterIndex === -1){ + words.push({ + id: `word-${Date.now()}-${index}`, + word: trimmedLine, + topics: [] + }) + return; + } const word = trimmedLine.substring(0, delimiterIndex).trim(); const topicsStr = trimmedLine.substring(delimiterIndex + 1).trim(); @@ -638,7 +645,7 @@ export default function WordsAddPage() { } } const regex = /^[0-9ㄱ-힣]*$/; - return !regex.test(entry.word) || p || entry.topics.length === 0; + return !regex.test(entry.word) || p; }; const formatFileSize = (bytes: number) => { diff --git a/app/word/components/WordAddFrom.tsx b/app/word/components/WordAddFrom.tsx index 70bfa43..f85e4db 100644 --- a/app/word/components/WordAddFrom.tsx +++ b/app/word/components/WordAddFrom.tsx @@ -597,7 +597,7 @@ const WordAddForm = ({ saveFn, initWord = "", initThemes = [] }: WordAddFormProp - - - {searchPerformed && ( -
    -
    -

    - 검색 결과 {results.length > 0 ? `(${results.length}개)` : ''} -

    -
    - - {loading ? ( -
    - - 검색 중... -
    - ) : results.length > 0 ? ( -
      - {results.map((word, index) => ( -
    • -
      -
      - - {word} -
      - - 상세보기 - - -
      -
    • - ))} -
    - ) : ( -
    -

    검색 결과가 없습니다

    -

    다른 검색어로 시도해보세요

    -
    - )} -
    - )} - -
    - 정확한 단어를 입력하시거나, 5글자 이상 입력하시면 시작 부분이 일치하는 단어를 검색합니다
    + + {/* 검색 결과 */} + + + {/* 모드 선택 모달 */} + setShowModeModal(false)} + currentMode={mode} + onSelectMode={(m) => { + setMode(m); + setShowModeModal(false); + setSearchPerformed(false); + setResults([]); + if (m === 'kung') { + setMinLength(3); + setMaxLength(3); + if (sortBy === 'length') setSortBy('abc'); + } + }} + /> + + {/* 주제 선택 모달 */} + setShowThemeModal(false)} + selectedTheme={selectedTheme} + onSelectTheme={(theme) => setSelectedTheme(theme)} + /> ); - -} \ No newline at end of file +} diff --git a/app/word/search/components/ModeSelectionModal.tsx b/app/word/search/components/ModeSelectionModal.tsx new file mode 100644 index 0000000..fc7776d --- /dev/null +++ b/app/word/search/components/ModeSelectionModal.tsx @@ -0,0 +1,50 @@ +import { X } from 'lucide-react'; +import { GameMode } from '../types'; +import { getModeLabel } from '../utils'; + +interface ModeSelectionModalProps { + isOpen: boolean; + onClose: () => void; + currentMode: GameMode; + onSelectMode: (mode: GameMode) => void; +} + +export default function ModeSelectionModal({ + isOpen, + onClose, + currentMode, + onSelectMode +}: ModeSelectionModalProps) { + if (!isOpen) return null; + + return ( +
    +
    +
    +

    검색 모드 선택

    + +
    +
    + {(['kor-start', 'kor-end', 'kung', 'hunmin', 'jaqi'] as GameMode[]).map((m) => ( + + ))} +
    +
    +
    + ); +} diff --git a/app/word/search/components/SearchHeader.tsx b/app/word/search/components/SearchHeader.tsx new file mode 100644 index 0000000..f7f2e17 --- /dev/null +++ b/app/word/search/components/SearchHeader.tsx @@ -0,0 +1,29 @@ +import { Settings } from 'lucide-react'; +import WordSearchHelpModal from './WordSearchHelpModal'; +import { GameMode } from '../types'; +import { getModeLabel } from '../utils'; + +interface SearchHeaderProps { + mode: GameMode; + onOpenModeModal: () => void; +} + +export default function SearchHeader({ mode, onOpenModeModal }: SearchHeaderProps) { + return ( +
    +
    +

    + 단어 고급 검색 +

    + +
    + +
    + ); +} diff --git a/app/word/search/components/SearchOptions.tsx b/app/word/search/components/SearchOptions.tsx new file mode 100644 index 0000000..3d4a6a0 --- /dev/null +++ b/app/word/search/components/SearchOptions.tsx @@ -0,0 +1,293 @@ +import { Search, Loader2, Tag } from 'lucide-react'; +import { GameMode } from '../types'; + +interface SearchOptionsProps { + mode: GameMode; + startLetter: string; + setStartLetter: (v: string) => void; + endLetter: string; + setEndLetter: (v: string) => void; + mission: string; + setMission: (v: string) => void; + minLength: number; + setMinLength: (v: number) => void; + maxLength: number; + setMaxLength: (v: number) => void; + sortBy: 'abc' | 'length' | 'attack'; + setSortBy: (v: 'abc' | 'length' | 'attack') => void; + duem: boolean; + setDuem: (v: boolean) => void; + miniInfo: boolean; + setMiniInfo: (v: boolean) => void; + manner: '' | 'man' | 'jen' | 'eti'; + setManner: (v: '' | 'man' | 'jen' | 'eti') => void; + ingjung: boolean; + setIngjung: (v: boolean) => void; + simpleQuery: string; + setSimpleQuery: (v: string) => void; + displayLimit: string; + setDisplayLimit: (v: string) => void; + loading: boolean; + handleSearch: () => void; + onOpenThemeModal: () => void; + selectedTheme: { id: number; name: string } | null; +} + +export default function SearchOptions({ + mode, + startLetter, setStartLetter, + endLetter, setEndLetter, + mission, setMission, + minLength, setMinLength, + maxLength, setMaxLength, + sortBy, setSortBy, + duem, setDuem, + miniInfo, setMiniInfo, + manner, setManner, + ingjung, setIngjung, + simpleQuery, setSimpleQuery, + displayLimit, setDisplayLimit, + loading, + handleSearch, + onOpenThemeModal, + selectedTheme +}: SearchOptionsProps) { + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSearch(); + } + }; + + const getSelectedThemeName = () => { + if (!selectedTheme) return '주제 미선택'; + return selectedTheme.name; + }; + + if (mode === 'kor-start' || mode === 'kor-end' || mode === 'kung') { + return ( +
    +
    + setStartLetter(e.target.value)} + onKeyDown={handleKeyPress} + placeholder="시작 글자" + className="px-4 py-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + setEndLetter(e.target.value)} + onKeyDown={handleKeyPress} + placeholder="끝 글자" + className="px-4 py-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + setMission(e.target.value)} + onKeyDown={handleKeyPress} + placeholder="미션 글자" + className="px-4 py-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
    + +
    +
    + + setMinLength(parseInt(e.target.value) || 2)} + disabled={mode === 'kung'} + min={2} + max={10} + className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700" + /> +
    +
    + + setMaxLength(parseInt(e.target.value) || 10)} + disabled={mode === 'kung'} + min={2} + max={10} + className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-700" + /> +
    +
    + + setDisplayLimit(e.target.value)} + min={-1} + placeholder="-1: 제한없음" + className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
    +
    + +
    + + + + + + + + +
    + + + + +
    +
    + + +
    + ); + } + + return ( +
    +
    + setSimpleQuery(e.target.value)} + onKeyDown={handleKeyPress} + placeholder={mode === 'hunmin' ? '검색할 단어 입력' : '자음 입력'} + maxLength={mode === 'hunmin' ? 2 : undefined} + className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + +
    + {mode === 'hunmin' && ( + setMission(e.target.value)} + onKeyDown={handleKeyPress} + placeholder="미션 글자" + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + )} +
    + + {mode === 'jaqi' && ( + <> + + + 선택된 주제: {getSelectedThemeName()} + + + )} +
    +
    + ); +} diff --git a/app/word/search/components/SearchResults.tsx b/app/word/search/components/SearchResults.tsx new file mode 100644 index 0000000..ce90924 --- /dev/null +++ b/app/word/search/components/SearchResults.tsx @@ -0,0 +1,151 @@ +import { useRef } from 'react'; +import Link from 'next/link'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { BookOpen, ArrowRight, Search, Loader2 } from 'lucide-react'; +import { GameMode, SearchResult } from '../types'; +import { countMissionChars } from '../utils'; + +interface SearchResultsProps { + results: SearchResult[]; + searchPerformed: boolean; + loading: boolean; + mission: string; + miniInfo: boolean; + mode: GameMode; +} + +export default function SearchResults({ + results, + searchPerformed, + loading, + mission, + miniInfo, + mode +}: SearchResultsProps) { + const parentRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: results.length, + getScrollElement: () => parentRef.current, + estimateSize: () => (miniInfo ? 80 : 64), + overscan: 5, + }); + + const handleResultDownload = () => { + const blob = new Blob([results.map(({word})=>word).join('\n')], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'results.txt'; + a.click(); + URL.revokeObjectURL(url); + } + + const highlightMission = (word: string, missionChars: string) => { + if (!missionChars) return word; + + const chars = word.split(''); + return chars.map((char, idx) => { + if (missionChars.includes(char)) { + return {char}; + } + return char; + }); + }; + + return ( +
    +
    +

    + 검색 결과 {searchPerformed && results.length > 0 ? `(${results.length}개)` : ''} +

    + +
    + +
    + {!searchPerformed ? ( +
    +
    + +

    검색 조건을 설정하고 검색하세요

    +
    +
    + ) : loading ? ( +
    + + 검색 중... +
    + ) : results.length > 0 ? ( +
    + {virtualizer.getVirtualItems().map((virtualItem) => { + const word = results[virtualItem.index]; + return ( +
    +
    +
    + +
    + + {highlightMission(word.word, mission)} + + {miniInfo && ( + + 길이: {word.word.length}글자 + {mission && countMissionChars(word.word, mission) > 0 && ( + <> · 미션글자 포함: {countMissionChars(word.word, mission)}개 + )} + {(mode !== "hunmin" && mode !== "jaqi" ) && <> · 후속 단어 수: {word.nextWordCount}} + + )} +
    +
    + + 상세보기 + + +
    +
    + ); + })} +
    + ) : ( +
    +
    +

    검색 결과가 없습니다

    +

    다른 검색 조건으로 시도해보세요

    +
    +
    + )} +
    +
    + ); +} diff --git a/app/word/search/components/ThemeSelectionModal.tsx b/app/word/search/components/ThemeSelectionModal.tsx new file mode 100644 index 0000000..bf116fd --- /dev/null +++ b/app/word/search/components/ThemeSelectionModal.tsx @@ -0,0 +1,214 @@ +import { useState } from 'react'; +import useSWR from 'swr'; +import { X, Loader2 } from 'lucide-react'; +import { SCM } from '@/app/lib/supabaseClient'; +import { Theme } from '../types'; + +interface ThemeSelectionModalProps { + isOpen: boolean; + onClose: () => void; + selectedTheme: { id: number; name: string } | null; + onSelectTheme: (theme: { id: number; name: string } | null) => void; +} + +export default function ThemeSelectionModal({ + isOpen, + onClose, + selectedTheme, + onSelectTheme +}: ThemeSelectionModalProps) { + const [themeSearchQuery, setThemeSearchQuery] = useState(''); + + const { data: themes, error: themesError } = useSWR( + isOpen ? 'themes' : null, + async () => { + const { data, error } = await SCM.get().allThemes(); + if (error) throw error; + return data; + } + ); + + if (!isOpen) return null; + + const filteredAndGroupedThemes = () => { + if (!themes) return { groupA: [], groupB: [] }; + + const filtered = themeSearchQuery.trim() === '' + ? themes + : themes.filter(theme => + theme.name.toLowerCase().includes(themeSearchQuery.toLowerCase()) || + theme.code.toLowerCase().includes(themeSearchQuery.toLowerCase()) + ); + + const groupA = filtered.filter(theme => /^\d+$/.test(theme.code)); + const groupB = filtered.filter(theme => !/^\d+$/.test(theme.code)); + + return { groupA, groupB }; + }; + + return ( +
    +
    +
    +

    주제 선택

    + +
    + +
    + setThemeSearchQuery(e.target.value)} + placeholder="주제 검색..." + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-purple-500" + /> +
    + +
    + {!themes ? ( +
    + + 주제 로딩 중... +
    + ) : themesError ? ( +
    +

    주제를 불러오는 중 오류가 발생했습니다

    +
    + ) : (() => { + const { groupA, groupB } = filteredAndGroupedThemes(); + return ( +
    + {themeSearchQuery.trim() === '' ? ( + <> + {/* 전체 선택 옵션 */} +
    + +
    + + {/* 그룹 A: 숫자 코드 */} + {groupA.length > 0 && ( +
    +

    +
    + 노인정 주제 +

    +
    + {groupA.sort((a,b) => a.name.localeCompare(b.name, 'ko')).map((theme) => ( + + ))} +
    +
    + )} + + {/* 그룹 B: 기타 코드 */} + {groupB.length > 0 && ( +
    +

    +
    + 어인정 주제 +

    +
    + {groupB.sort((a,b) => a.name.localeCompare(b.name, 'ko')).map((theme) => ( + + ))} +
    +
    + )} + + ) : ( + <> + {/* 검색 결과 */} + {groupA.length === 0 && groupB.length === 0 ? ( +
    +

    검색 결과가 없습니다

    +
    + ) : ( +
    + {[...groupA, ...groupB].sort((a,b) => a.name.localeCompare(b.name, 'ko')).map((theme) => ( + + ))} +
    + )} + + )} +
    + ); + })()} +
    + +
    + +
    +
    +
    + ); +} diff --git a/app/word/search/components/WordSearchHelpModal.tsx b/app/word/search/components/WordSearchHelpModal.tsx new file mode 100644 index 0000000..3943f1e --- /dev/null +++ b/app/word/search/components/WordSearchHelpModal.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import HelpModal from '@/app/components/HelpModal'; + +const WordSearchHelpModal = () => { + return ( + +
    +
    +

    🎮 검색 모드

    +
      +
    • + 한국어 끝말잇기: 시작 글자로 시작하는 단어를 검색합니다. +
    • +
    • + 한국어 앞말잇기: 끝 글자로 끝나는 단어를 검색합니다. +
    • +
    • + 쿵쿵따: 3글자 단어를 검색합니다. 시작/끝 글자를 최대 3글자까지 입력할 수 있습니다. +
    • +
    • + 훈민정음: 정확히 2글자 자음을 입력하여 해당 자음인 단어를 검색합니다. +
    • +
    • + 자음퀴즈: 자음을 입력하여 해당 자음으로 시작하는 단어를 검색합니다. 주제를 선택해야 검색할 수 있습니다. +
    • +
    +
    + +
    +

    ⚙️ 검색 옵션 (끝말잇기/앞말잇기/쿵쿵따)

    +
      +
    • + 시작/끝 글자: 검색할 단어의 첫/마지막 글자를 지정합니다. +
    • +
    • + 미션 글자: 단어에 반드시 포함되어야 할 글자를 입력합니다. 여러 글자 입력 가능. +
    • +
    • + 최소/최대 글자수: 검색할 단어의 길이 범위를 지정합니다. (2-100글자) +
    • +
    • + 표시 개수: 검색 결과로 표시할 최대 단어 수를 설정합니다. -1을 입력하면 제한 없음. +
    • +
    +
    + +
    +

    📊 정렬 및 필터

    +
      +
    • + 가나다순: 단어를 가나다 순으로 정렬합니다. +
    • +
    • + 길이순: 단어 길이를 기준으로 정렬합니다. +
    • +
    • + 공격단어순: 후속 단어가 적은 순서대로 정렬합니다. (공격적인 단어 우선) +
    • +
    +
    + +
    +

    ✅ 체크 옵션

    +
      +
    • + 두음법칙: 두음법칙을 적용하여 검색합니다. (예: 리 → 이, 녀 → 여) +
    • +
    • + 간단 정보: 각 단어의 길이, 미션 글자 포함 개수, 후속 단어 수를 표시합니다. +
    • +
    • + 어인정: 어인정 단어를 허용합니다. +
    • +
    +
    + +
    +

    🎯 매너 모드

    +
      +
    • + 없음: 한방 단어를 표시합니다. +
    • +
    • + 매너: 한방 단어를 표시 하지 않습니다. +
    • +
    • + 젠틀: 후속단어 수가 5개 미만인 단어를 표시 하지 않습니다. +
    • +
    • + 에티켓: 노인정을 기준으로 한방 글자를 산정한 한방단어를 표시하지 않습니다. +
    • +
    +
    + +
    +

    🏷️ 주제 선택 (자음퀴즈)

    +
      +
    • + 자음퀴즈 모드에서는 반드시 주제를 선택해야 검색할 수 있습니다. +
    • +
    • + 노인정 주제: 구표준국어대사전에 있던 주제들입니다. +
    • +
    • + 어인정 주제: 끄투코리아에서 추가한 주제들입니다. +
    • +
    • + 주제 검색 기능을 사용하여 원하는 주제를 빠르게 찾을 수 있습니다. +
    • +
    +
    + +
    +

    💡 검색 결과

    +
      +
    • + 미션 글자 하이라이트: 미션 글자가 입력된 경우, 결과에서 해당 글자가 연두색으로 표시됩니다. +
    • +
    • + 결과 다운로드: 검색 결과를 텍스트 파일로 다운로드할 수 있습니다. +
    • +
    • + 상세보기: 각 단어를 클릭하면 단어의 상세 정보를 확인할 수 있습니다. +
    • +
    +
    + +
    +

    ⌨️ 단축키

    +
      +
    • + Enter: 입력 필드에서 Enter 키를 눌러 빠르게 검색할 수 있습니다. +
    • +
    +
    + +
    +

    + 💡 팁: 공격단어순 정렬을 사용하면 상대방이 대응하기 어려운 단어를 찾을 수 있습니다! +

    +
    +
    +
    + ); +}; + +export default WordSearchHelpModal; diff --git a/app/word/search/hooks/useWordSearch.ts b/app/word/search/hooks/useWordSearch.ts new file mode 100644 index 0000000..0ad7585 --- /dev/null +++ b/app/word/search/hooks/useWordSearch.ts @@ -0,0 +1,123 @@ +import { useState } from 'react'; +import { SCM } from '@/app/lib/supabaseClient'; +import { advancedQueryType } from '@/app/types/type'; +import { GameMode, SearchResult } from '../types'; + +export const useWordSearch = () => { + const [mode, setMode] = useState('kor-start'); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [searchPerformed, setSearchPerformed] = useState(false); + + // 검색 파라미터 + const [startLetter, setStartLetter] = useState(''); + const [endLetter, setEndLetter] = useState(''); + const [mission, setMission] = useState(''); + const [minLength, setMinLength] = useState(2); + const [maxLength, setMaxLength] = useState(100); + const [sortBy, setSortBy] = useState<'abc' | 'length' | 'attack'>('length'); + const [duem, setDuem] = useState(true); + const [miniInfo, setMiniInfo] = useState(false); + const [manner, setManner] = useState<''|'man' | 'jen' | 'eti'>('man'); + const [ingjung, setIngjung] = useState(true); + const [simpleQuery, setSimpleQuery] = useState(''); + const [displayLimit, setDisplayLimit] = useState('100'); + const [selectedTheme, setSelectedTheme] = useState<{id: number, name: string} | null>(null); + + const handleSearch = async () => { + setLoading(true); + setSearchPerformed(true); + setResults([]); + + try { + let query: advancedQueryType; + + if (mode === 'kor-start' || mode === 'kor-end') { + if (mode === 'kor-start' && startLetter.trim() === '') return; + if (mode === 'kor-end' && endLetter.trim() === '') return; + query = { + mode, + start: startLetter?.trim() || undefined, + end: endLetter?.trim() || undefined, + mission, + ingjung, + man: manner === 'man', + jen: manner === 'jen', + eti: manner === 'eti', + duem, + miniInfo, + length_min: minLength, + length_max: maxLength, + sort_by: sortBy, + limit: displayLimit === '' || isNaN(Number(displayLimit)) ? 100 : Number(displayLimit) + }; + } else if (mode === 'kung') { + if (startLetter.trim() === '') return; + query = { + mode: 'kung', + start: startLetter?.trim().slice(0,3) || undefined, + end: endLetter?.trim().slice(0,3) || undefined, + mission, + ingjung, + man: manner === 'man', + jen: manner === 'jen', + eti: manner === 'eti', + duem, + miniInfo, + length_min: 3, + length_max: 3, + sort_by: sortBy, + limit: displayLimit === '' || isNaN(Number(displayLimit)) ? 100 : Number(displayLimit) + }; + } else if (mode === 'hunmin') { + if (simpleQuery.trim() === '' || simpleQuery.trim().length !== 2) return; + query = { + mode: 'hunmin', + query: simpleQuery.trim(), + mission, + limit: displayLimit === '' || isNaN(Number(displayLimit)) ? 100 : Number(displayLimit) + }; + } else { + if (!selectedTheme) return; + query = { + mode: 'jaqi', + query: simpleQuery.trim(), + theme: selectedTheme.id, + limit: displayLimit === '' || isNaN(Number(displayLimit)) ? 100 : Number(displayLimit) + }; + } + + const { data, error } = await SCM.get().wordsByAdvancedQuery(query); + if (error) { + console.error('검색 오류:', error); + return; + } + setResults(data); + } catch (error) { + console.error('검색 중 오류 발생:', error); + } finally { + setLoading(false); + } + }; + + return { + mode, setMode, + results, setResults, + loading, setLoading, + searchPerformed, setSearchPerformed, + startLetter, setStartLetter, + endLetter, setEndLetter, + mission, setMission, + minLength, setMinLength, + maxLength, setMaxLength, + sortBy, setSortBy, + duem, setDuem, + miniInfo, setMiniInfo, + manner, setManner, + ingjung, setIngjung, + simpleQuery, setSimpleQuery, + displayLimit, setDisplayLimit, + selectedTheme, setSelectedTheme, + handleSearch + }; +}; diff --git a/app/word/search/types.ts b/app/word/search/types.ts new file mode 100644 index 0000000..67a5d61 --- /dev/null +++ b/app/word/search/types.ts @@ -0,0 +1,26 @@ +import { Database } from '@/app/types/database.types'; + +export type GameMode = 'kor-start' | 'kor-end' | 'kung' | 'hunmin' | 'jaqi'; +export type Theme = Database['public']['Tables']['themes']['Row']; + +export interface SearchResult { + word: string; + nextWordCount: number; +} + +export interface SearchState { + mode: GameMode; + startLetter: string; + endLetter: string; + mission: string; + minLength: number; + maxLength: number; + sortBy: 'abc' | 'length' | 'attack'; + duem: boolean; + miniInfo: boolean; + manner: '' | 'man' | 'jen' | 'eti'; + ingjung: boolean; + simpleQuery: string; + displayLimit: string; + selectedTheme: { id: number; name: string } | null; +} diff --git a/app/word/search/utils.ts b/app/word/search/utils.ts new file mode 100644 index 0000000..3508e5a --- /dev/null +++ b/app/word/search/utils.ts @@ -0,0 +1,16 @@ +import { GameMode } from './types'; + +export const getModeLabel = (m: GameMode) => { + switch (m) { + case 'kor-start': return '한국어 끝말잇기'; + case 'kor-end': return '한국어 앞말잇기'; + case 'kung': return '쿵쿵따'; + case 'hunmin': return '훈민정음'; + case 'jaqi': return '자음퀴즈'; + } +}; + +export const countMissionChars = (word: string, missionChars: string) => { + if (!missionChars) return 0; + return word.split('').filter(char => missionChars.includes(char)).length; +}; From 8f3ab376ca2613d75e3e948b88b521edd56bd88b Mon Sep 17 00:00:00 2001 From: JUNG TAEWON <153927840+hafskjfha@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:02:16 +0000 Subject: [PATCH 10/20] =?UTF-8?q?fix:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=EC=97=90=20=EB=A7=81=ED=81=AC=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/supabase/ISupabaseClientManager.ts | 2 +- app/release-note/ReleaseNote.tsx | 15 +++++++++++++++ app/word/search/[query]/WordInfo.tsx | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/lib/supabase/ISupabaseClientManager.ts b/app/lib/supabase/ISupabaseClientManager.ts index d55764a..a88084c 100644 --- a/app/lib/supabase/ISupabaseClientManager.ts +++ b/app/lib/supabase/ISupabaseClientManager.ts @@ -59,7 +59,7 @@ export interface IGetManager{ allWords({ includeAddReq, includeDeleteReq, includeInjung, includeNoInjung, onlyWordChain, lenf }: { includeAddReq?: boolean; includeDeleteReq?: boolean; includeInjung?: boolean; includeNoInjung?: boolean; onlyWordChain?: boolean; lenf?: boolean; }): Promise<{ data: { word: string; noin_canuse: boolean; k_canuse: boolean; status: "ok" | "add" | "delete"; }[]; error: null } | {data: null; error: PostgrestError; }> letterDocs(): Promise>; addWaitDocs(): Promise>; - releaseNote(): Promise>; + releaseNote(): Promise>; userById(userId: string): Promise>; session(): Promise<{data: {session: Session}, error: null} | {data: { session: null}, error: AuthError} | { data: {session: null}, error: null}>; usersByNickname(userName: string): Promise>; diff --git a/app/release-note/ReleaseNote.tsx b/app/release-note/ReleaseNote.tsx index 4231e1b..ffa4bfa 100644 --- a/app/release-note/ReleaseNote.tsx +++ b/app/release-note/ReleaseNote.tsx @@ -9,6 +9,7 @@ interface Note { content: string; created_at: string; title: string; + link: string | null; } const ReleaseNote = () => { @@ -132,6 +133,20 @@ const ReleaseNote = () => {
    {note.content}
    + {note.link && ( + + )} )} diff --git a/app/word/search/[query]/WordInfo.tsx b/app/word/search/[query]/WordInfo.tsx index 5206442..c373aaf 100644 --- a/app/word/search/[query]/WordInfo.tsx +++ b/app/word/search/[query]/WordInfo.tsx @@ -348,7 +348,7 @@ const WordInfo = ({ wordInfo }: { wordInfo: WordInfoProps }) => { {/* 마지막 글자로 끝나는 단어 버튼 */} - {!goLastLetterWords ? ( + {goLastLetterWords === null ? (
    로드중 From 7e8ce61f25d54ab0ac433e04d1225d87bff351ae Mon Sep 17 00:00:00 2001 From: JUNG TAEWON <153927840+hafskjfha@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:07:03 +0000 Subject: [PATCH 11/20] =?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=20=EC=B4=9D=20=EB=8B=A8=EC=96=B4?= =?UTF-8?q?=EC=88=98=20=ED=91=9C=EC=8B=9C=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/supabase/SupabaseClientManager.ts | 6 +++--- app/types/database.types.ts | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/app/lib/supabase/SupabaseClientManager.ts b/app/lib/supabase/SupabaseClientManager.ts index 13872ca..11c9785 100644 --- a/app/lib/supabase/SupabaseClientManager.ts +++ b/app/lib/supabase/SupabaseClientManager.ts @@ -313,9 +313,9 @@ class GetManager implements IGetManager { return await this.supabase.from("logs").select("*").eq("make_by", userId).order("created_at", { ascending: false }).limit(30); } public async wordsCount(){ - const { data, error } = await this.supabase.from('word_last_letter_counts').select('count'); - if (error) return { count: null, error }; - return { count: sum(data?.map(({ count }) => count) ?? []) ?? 0, error: null }; + const {data, error} = await this.supabase.from('words_count').select('total_words').single(); + if (error) return {count: null, error}; + return {count: data?.total_words ?? 0, error: null}; } public async waitWordsCount() { const {count: count1, error: error1} = await this.supabase.from('wait_words').select('word',{ count: 'exact', head: true }); diff --git a/app/types/database.types.ts b/app/types/database.types.ts index 4b6fcc2..d45f614 100644 --- a/app/types/database.types.ts +++ b/app/types/database.types.ts @@ -659,6 +659,21 @@ export type Database = { }, ] } + words_count: { + Row: { + id: number + total_words: number + } + Insert: { + id?: number + total_words: number + } + Update: { + id?: number + total_words?: number + } + Relationships: [] + } } Views: { [_ in never]: never From 6db6f225f01c62daad3d8876e2ee6ced0b9937b7 Mon Sep 17 00:00:00 2001 From: JUNG TAEWON <153927840+hafskjfha@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:37:17 +0000 Subject: [PATCH 12/20] =?UTF-8?q?fix:=20=EA=B2=80=EC=83=89=20=EB=94=9C?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=20=EC=B6=94=EA=B0=80,=20=EB=8B=A8=EC=96=B4?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=ED=91=9C=EC=8B=9C=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=ED=95=98=EB=8A=94=20=EB=B2=84=EA=B7=B8=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 --- app/lib/supabase/ISupabaseClientManager.ts | 6 +- app/lib/supabase/SupabaseClientManager.ts | 148 +++++++++++------- app/word/search/WordSearch.tsx | 62 +++++--- app/word/search/[query]/WordInfoPage.tsx | 16 +- app/word/search/components/SearchHeader.tsx | 46 ++++-- .../search/components/SimpleSearchOptions.tsx | 44 ++++++ .../search/components/WordSearchHelpModal.tsx | 5 + app/word/search/hooks/useWordSearch.ts | 26 ++- 8 files changed, 250 insertions(+), 103 deletions(-) create mode 100644 app/word/search/components/SimpleSearchOptions.tsx diff --git a/app/lib/supabase/ISupabaseClientManager.ts b/app/lib/supabase/ISupabaseClientManager.ts index a88084c..f6c573f 100644 --- a/app/lib/supabase/ISupabaseClientManager.ts +++ b/app/lib/supabase/ISupabaseClientManager.ts @@ -80,10 +80,8 @@ export interface IGetManager{ wordThemeWaitByWordId(wordId: number): Promise>; letterDocsByWord(word: string): Promise>; themeDocsByThemeNames(themeNames: string[]): Promise>; - firstWordCountByLetters(letters: string[]): Promise; - lastWordCountByLetters(letters: string[]): Promise; - /** - * @deprecated Use wordsByWords or wordsByAdvancedQuery instead */ + firstWordCountByLetters(letter: string): Promise; + lastWordCountByLetters(letter: string): Promise; wordsByQuery(query: string): Promise<{data: string[], error: null} | {data: null; error: PostgrestError}>; logsByFillter({filterState, filterType, from, to}:{filterState?: "approved" | "rejected" | "pending" | "all", filterType: "delete" | "add" | "all", from: number, to: number}): Promise> docsLogsByFilter({ docsName, logType, from, to }: { docsName?: string; logType: 'add' | 'delete' | 'all'; from: number; to: number; }): Promise>; diff --git a/app/lib/supabase/SupabaseClientManager.ts b/app/lib/supabase/SupabaseClientManager.ts index 11c9785..63f5a1f 100644 --- a/app/lib/supabase/SupabaseClientManager.ts +++ b/app/lib/supabase/SupabaseClientManager.ts @@ -365,70 +365,74 @@ class GetManager implements IGetManager { public async themeDocsByThemeNames(themeNames: string[]){ return this.supabase.from('docs').select('*').eq('typez','theme').in('name',themeNames); } - public async firstWordCountByLetters(letters: string[]) { + public async firstWordCountByLetters(letter: string) { const { data: firWordsCount1, error: firWordsError1 } = await this.supabase .from('word_last_letter_counts') .select('*') - .in('last_letter', letters); + .eq('last_letter', letter); const { count: firWordsCount2, error: firWordsError2 } = await this.supabase .from('wait_words') .select('*', { count: 'exact', head: true }) - .or(letters.map(c => `word.ilike.%${c}`).join(',')); + .or(reverDuemLaw(letter).map(c => `word.ilike.%${c}`).join(',')); if (firWordsError1 || firWordsError2) return 0; return (sum((firWordsCount1 ?? []).map(({count})=>count))) + (firWordsCount2 || 0); } - public async lastWordCountByLetters(letters: string[]) { + public async lastWordCountByLetters(letter: string) { const { data: lasWordsCount1, error: lasWordsError1 } = await this.supabase .from('word_first_letter_counts') .select('*') - .in('first_letter', letters); + .eq('first_letter', letter); const { count: lasWordsCount2, error: lasWordsError2 } = await this.supabase .from('wait_words') .select('*', { count: 'exact', head: true }) - .or(letters.map(c => `word.ilike.${c}%`).join(',')); + .or([...new Set([letter,DuemLaw(letter)])].map(c => `word.ilike.${c}%`).join(',')); if (lasWordsError1 || lasWordsError2) return 0; return (sum((lasWordsCount1 ?? []).map(({count})=>count))) + (lasWordsCount2 || 0) } - /** - * @deprecated Use wordsByWords or wordsByAdvancedQuery instead - */ public async wordsByQuery(query: string) { + const startTime = Date.now(); + const cleanQuery = query.trim().replace(/[^ㄱ-힣a-zA-Z0-9]/g, ''); - let dbqueryA = this.supabase.from('words').select('word') - if (cleanQuery.length > 4) { - dbqueryA = dbqueryA.ilike('word', `${cleanQuery}%`); - } else { - dbqueryA = dbqueryA.eq('word', cleanQuery); - } - const { data: getWords, error: getWordsError } = await dbqueryA; - if (getWordsError) return {data: null, error: getWordsError} - let dbqueryB = this.supabase.from('wait_words').select('word'); - if (cleanQuery.length > 4) { - dbqueryB = dbqueryB.ilike('word', `${cleanQuery}%`); - } else { - dbqueryB = dbqueryB.eq('word', cleanQuery); - } - const { data: getWaitWords, error: getWaitWordsError } = await dbqueryB; - if (getWaitWordsError) return {data: null, error: getWaitWordsError} + const { data: getWords, error: getWordsError } = await this.supabase + .from('words') + .select('word') + .ilike('word', `${cleanQuery}%`); + if (getWordsError) return { data: null, error: getWordsError }; + + const { data: getWaitWords, error: getWaitWordsError } = await this.supabase + .from('wait_words') + .select('word') + .ilike('word', `${cleanQuery}%`); + if (getWaitWordsError) return { data: null, error: getWaitWordsError }; const words = getWords.map((item) => item.word) || []; const waitWords = getWaitWords.map((item) => item.word) || []; const allWords = [...words]; - const wordsSet = new Set(words) - waitWords.forEach((word)=>{ - if (!wordsSet.has(word)){ - allWords.push(word) + const wordsSet = new Set(words); + waitWords.forEach((word) => { + if (!wordsSet.has(word)) { + allWords.push(word); } - }) - return {data: allWords.sort((a,b)=>a.length - b.length), error: null} + }); + + const result = { data: allWords.sort((a, b) => a.length - b.length), error: null }; + + const elapsed = Date.now() - startTime; + const remaining = 2000 - elapsed; + if (remaining > 0) { + await new Promise((resolve) => setTimeout(resolve, remaining)); + } + + return result; } + public async letterCountInfo(){ const now = Date.now(); if (this.wordLetterCountsCacheTime !== 0 && now - this.wordLetterCountsCacheTime < CACHE_DURATION){ @@ -461,12 +465,16 @@ class GetManager implements IGetManager { }, error: null} } public async wordsByAdvancedQuery(input: advancedQueryType) { + const startTime = Date.now(); // 시작 시간 기록 + const { data: letterData, error: letterError } = await this.letterCountInfo(); if (letterError) return { data: null, error: letterError }; + let result: {data: {word: string, nextWordCount: number}[], error: null} | {data: null; error: PostgrestError} = { data: [], error: null }; + switch (input.mode) { - case 'kor-start': - const { data, error} = await this.supabase.rpc('get_korean_words_advanced_s',{ + case 'kor-start': { + const { data, error } = await this.supabase.rpc('get_korean_words_advanced_s', { p_start: input.start, p_end: input.end, p_length_max: input.length_max, @@ -480,11 +488,15 @@ class GetManager implements IGetManager { p_sort_by: input.sort_by, p_duem: input.duem }); - if (error) return {data: null, error}; - return { data: data.map((word) => ({ word: word.word, nextWordCount: letterData.firstLetterCounts[word.word[word.word.length - 1]]?.[input.ingjung ? 'k_count' : 'n_count'] ?? 0 })), error: null }; - - case 'kor-end': - const { data: data2, error: error2} = await this.supabase.rpc('get_korean_words_advanced_e',{ + if (error) return { data: null, error }; + result.data = data.map((word) => ({ + word: word.word, + nextWordCount: letterData.firstLetterCounts[word.word[word.word.length - 1]]?.[input.ingjung ? 'k_count' : 'n_count'] ?? 0 + })); + break; + } + case 'kor-end': { + const { data, error } = await this.supabase.rpc('get_korean_words_advanced_e', { p_start: input.start, p_end: input.end, p_length_max: input.length_max, @@ -498,11 +510,15 @@ class GetManager implements IGetManager { p_sort_by: input.sort_by, p_duem: input.duem }); - if (error2) return {data: null, error: error2}; - return { data: data2.map((word) => ({ word: word.word, nextWordCount: letterData.lastLetterCounts[word.word[0]]?.[input.ingjung ? 'k_count' : 'n_count'] ?? 0 })), error: null }; - - case 'kung': - const {data: data3, error: error3} = await this.supabase.rpc('get_korean_words_advanced_kung',{ + if (error) return { data: null, error }; + result.data = data.map((word) => ({ + word: word.word, + nextWordCount: letterData.lastLetterCounts[word.word[0]]?.[input.ingjung ? 'k_count' : 'n_count'] ?? 0 + })); + break; + } + case 'kung': { + const { data, error } = await this.supabase.rpc('get_korean_words_advanced_kung', { p_start: input.start, p_end: input.end, p_man: input.man, @@ -513,27 +529,49 @@ class GetManager implements IGetManager { p_mission: input.mission, p_sort_by: input.sort_by }); - if (error3) return {data: null, error: error3}; - return { data: data3.map((word) => ({ word: word.word, nextWordCount: letterData.firstLetterCounts[word.word[word.word.length - 1]]?.[input.ingjung ? 'len3_k_count' : 'len3_n_count'] ?? 0 })), error: null}; - case 'hunmin': - const {data: data4, error: error4} = await this.supabase.rpc('get_korean_words_advanced_hunmin',{ + if (error) return { data: null, error }; + result.data = data.map((word) => ({ + word: word.word, + nextWordCount: letterData.firstLetterCounts[word.word[word.word.length - 1]]?.[input.ingjung ? 'len3_k_count' : 'len3_n_count'] ?? 0 + })); + break; + } + case 'hunmin': { + const { data, error } = await this.supabase.rpc('get_korean_words_advanced_hunmin', { p_chosungs: input.query, p_limit: input.limit, - p_mission: input.mission === '' ? undefined : input.mission, + p_mission: input.mission === '' ? undefined : input.mission }); - if (error4) return {data: null, error: error4}; - return { data: data4.map((word) => ({ word: word.word, nextWordCount: -1 })), error: null}; - case 'jaqi': - const {data: data5, error: error5} = await this.supabase.rpc('get_korean_words_advanced_jaqi',{ + if (error) return { data: null, error }; + result.data = data.map((word) => ({ word: word.word, nextWordCount: -1 })); + break; + } + case 'jaqi': { + const { data, error } = await this.supabase.rpc('get_korean_words_advanced_jaqi', { p_chosungs: input.query, p_theme_id: input.theme }); - if (error5) return {data: null, error: error5}; - return { data: data5.sort((a,b)=>b.word.length - a.word.length).map((word) => ({ word: word.word, nextWordCount: -1 })), error: null}; + if (error) return { data: null, error }; + result.data = data + .sort((a, b) => b.word.length - a.word.length) + .map((word) => ({ word: word.word, nextWordCount: -1 })); + break; + } default: - return {data: [], error: null}; + result.data = []; + result.error = null; + } + + // 최소 2초 맞추기 + const elapsed = Date.now() - startTime; + const remaining = 2000 - elapsed; + if (remaining > 0) { + await new Promise((resolve) => setTimeout(resolve, remaining)); } + + return result; } + public async logsByFillter({ filterState, filterType, from, to }: { filterState: 'approved' | 'rejected' | 'pending' | 'all'; filterType: 'delete' | 'add' | 'all'; from: number; to: number; }) { let query = this.supabase .from('logs') diff --git a/app/word/search/WordSearch.tsx b/app/word/search/WordSearch.tsx index 2bd0994..cbe6c60 100644 --- a/app/word/search/WordSearch.tsx +++ b/app/word/search/WordSearch.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import { useWordSearch } from './hooks/useWordSearch'; import SearchHeader from './components/SearchHeader'; import SearchOptions from './components/SearchOptions'; +import SimpleSearchOptions from './components/SimpleSearchOptions'; import SearchResults from './components/SearchResults'; import ModeSelectionModal from './components/ModeSelectionModal'; import ThemeSelectionModal from './components/ThemeSelectionModal'; @@ -13,6 +14,7 @@ export default function WordSearch() { const [showThemeModal, setShowThemeModal] = useState(false); const { + searchType, setSearchType, mode, setMode, results, setResults, loading, @@ -30,37 +32,55 @@ export default function WordSearch() { simpleQuery, setSimpleQuery, displayLimit, setDisplayLimit, selectedTheme, setSelectedTheme, - handleSearch + handleSearch, + handleSimpleSearch } = useWordSearch(); + const handleSearchTypeChange = (type: 'simple' | 'advanced') => { + setSearchType(type); + setSearchPerformed(false); + setResults([]); + }; + return (
    - {/* 제목 및 모드 선택, 검색 옵션 */} + {/* 제목 및 검색 타입, 검색 옵션 */}
    setShowModeModal(true)} /> - setShowThemeModal(true)} - selectedTheme={selectedTheme} - /> + {searchType === 'simple' ? ( + + ) : ( + setShowThemeModal(true)} + selectedTheme={selectedTheme} + /> + )}
    {/* 검색 결과 */} diff --git a/app/word/search/[query]/WordInfoPage.tsx b/app/word/search/[query]/WordInfoPage.tsx index 4b24460..746ec06 100644 --- a/app/word/search/[query]/WordInfoPage.tsx +++ b/app/word/search/[query]/WordInfoPage.tsx @@ -153,15 +153,11 @@ export default function WordInfoPage({ query }: { query: string }) { const docc = docsData3.concat(docsData4).map(({id,name})=>({doc_id: id, doc_name: name})) const ff = async () => { - const fir1 = reverDuemLaw(wordTableCheck.word[0]); - - return await SCM.get().firstWordCountByLetters(fir1); + return await SCM.get().firstWordCountByLetters(wordTableCheck.word[0]); } const lf = async () => { - const las1 = [DuemRaw(wordTableCheck.word[wordTableCheck.word.length - 1])]; - - return await SCM.get().lastWordCountByLetters(las1) + return await SCM.get().lastWordCountByLetters(wordTableCheck.word[wordTableCheck.word.length - 1]); } let kkukoWikiok = false @@ -217,16 +213,12 @@ export default function WordInfoPage({ query }: { query: string }) { const docc = waitDocsData2.concat(waitDocsData4).map(({id,name})=>({doc_id: id, doc_name: name})) const ff = async () => { - const fir1 = reverDuemLaw(waitTableCheck.word[0]); - - return await SCM.get().firstWordCountByLetters(fir1); + return await SCM.get().firstWordCountByLetters(waitTableCheck.word[0]); } const lf = async () => { - const las1 = [DuemRaw(waitTableCheck.word[waitTableCheck.word.length - 1])]; - - return await SCM.get().lastWordCountByLetters(las1); + return await SCM.get().lastWordCountByLetters(waitTableCheck.word[waitTableCheck.word.length - 1]); } updateLoadingState(90, "정보 가공 중..."); diff --git a/app/word/search/components/SearchHeader.tsx b/app/word/search/components/SearchHeader.tsx index f7f2e17..b855f38 100644 --- a/app/word/search/components/SearchHeader.tsx +++ b/app/word/search/components/SearchHeader.tsx @@ -4,26 +4,52 @@ import { GameMode } from '../types'; import { getModeLabel } from '../utils'; interface SearchHeaderProps { + searchType: 'simple' | 'advanced'; + setSearchType: (type: 'simple' | 'advanced') => void; mode: GameMode; onOpenModeModal: () => void; } -export default function SearchHeader({ mode, onOpenModeModal }: SearchHeaderProps) { +export default function SearchHeader({ searchType, setSearchType, mode, onOpenModeModal }: SearchHeaderProps) { return (
    -
    +

    - 단어 고급 검색 + 단어 검색

    +
    + + +
    - + {searchType === 'advanced' && ( + + )}
    ); } diff --git a/app/word/search/components/SimpleSearchOptions.tsx b/app/word/search/components/SimpleSearchOptions.tsx new file mode 100644 index 0000000..d71a94e --- /dev/null +++ b/app/word/search/components/SimpleSearchOptions.tsx @@ -0,0 +1,44 @@ +import { Search, Loader2 } from 'lucide-react'; + +interface SimpleSearchOptionsProps { + simpleQuery: string; + setSimpleQuery: (v: string) => void; + loading: boolean; + handleSearch: () => void; +} + +export default function SimpleSearchOptions({ + simpleQuery, + setSimpleQuery, + loading, + handleSearch, +}: SimpleSearchOptionsProps) { + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSearch(); + } + }; + + return ( +
    +
    + setSimpleQuery(e.target.value)} + onKeyDown={handleKeyPress} + placeholder="검색할 단어를 입력하세요" + className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + +
    +
    + ); +} diff --git a/app/word/search/components/WordSearchHelpModal.tsx b/app/word/search/components/WordSearchHelpModal.tsx index 3943f1e..1da1dae 100644 --- a/app/word/search/components/WordSearchHelpModal.tsx +++ b/app/word/search/components/WordSearchHelpModal.tsx @@ -139,6 +139,11 @@ const WordSearchHelpModal = () => { 💡 팁: 공격단어순 정렬을 사용하면 상대방이 대응하기 어려운 단어를 찾을 수 있습니다!

    +
    +

    + ⚠️ 주의: 검색후 딜레이가 2초 이상있습니다. +

    +
    ); diff --git a/app/word/search/hooks/useWordSearch.ts b/app/word/search/hooks/useWordSearch.ts index 0ad7585..0fe3ff6 100644 --- a/app/word/search/hooks/useWordSearch.ts +++ b/app/word/search/hooks/useWordSearch.ts @@ -4,6 +4,7 @@ import { advancedQueryType } from '@/app/types/type'; import { GameMode, SearchResult } from '../types'; export const useWordSearch = () => { + const [searchType, setSearchType] = useState<'simple' | 'advanced'>('simple'); const [mode, setMode] = useState('kor-start'); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); @@ -100,7 +101,29 @@ export const useWordSearch = () => { } }; + const handleSimpleSearch = async () => { + setLoading(true); + setSearchPerformed(true); + setResults([]); + + try { + if (simpleQuery.trim() === '') return; + + const { data, error } = await SCM.get().wordsByQuery(simpleQuery.trim()); + if (error) { + console.error('검색 오류:', error); + return; + } + setResults(data.map(word => ({ word, nextWordCount: -1 }))); + } catch (error) { + console.error('검색 중 오류 발생:', error); + } finally { + setLoading(false); + } + }; + return { + searchType, setSearchType, mode, setMode, results, setResults, loading, setLoading, @@ -118,6 +141,7 @@ export const useWordSearch = () => { simpleQuery, setSimpleQuery, displayLimit, setDisplayLimit, selectedTheme, setSelectedTheme, - handleSearch + handleSearch, + handleSimpleSearch }; }; From 65d89ffc6c76706f85232da35eb27fa65849325c Mon Sep 17 00:00:00 2001 From: JUNG TAEWON <153927840+hafskjfha@users.noreply.github.com> Date: Thu, 27 Nov 2025 00:11:32 +0000 Subject: [PATCH 13/20] =?UTF-8?q?feat:=20=EB=8B=A8=EC=96=B4=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20=ED=91=9C=EC=8B=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=A0=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/supabase/ISupabaseClientManager.ts | 3 + app/lib/supabase/SupabaseClientManager.ts | 8 + app/word/page.tsx | 13 +- app/word/search/hooks/useWordSearch.ts | 57 ++- app/word/search/page.tsx | 7 +- app/word/stats/WordStatsHome.tsx | 462 +++++++++++++++++++++ app/word/stats/page.tsx | 20 + 7 files changed, 566 insertions(+), 4 deletions(-) create mode 100644 app/word/stats/WordStatsHome.tsx create mode 100644 app/word/stats/page.tsx diff --git a/app/lib/supabase/ISupabaseClientManager.ts b/app/lib/supabase/ISupabaseClientManager.ts index f6c573f..b391d0f 100644 --- a/app/lib/supabase/ISupabaseClientManager.ts +++ b/app/lib/supabase/ISupabaseClientManager.ts @@ -16,6 +16,8 @@ type log = Database['public']['Tables']['logs']['Row']; type word_themes_wait = Database['public']['Tables']['word_themes_wait']['Row']; type wait_word_themes = Database['public']['Tables']['wait_word_themes']['Row']; type notification = Database['public']['Tables']['notification']['Row']; +type word_first_letter_counts = Database['public']['Tables']['word_first_letter_counts']['Row']; +type word_last_letter_counts = Database['public']['Tables']['word_last_letter_counts']['Row']; type delete_word_themes_bulk = Database['public']['Functions']['delete_word_themes_bulk']['Returns']; @@ -90,6 +92,7 @@ export interface IGetManager{ allUser(sortField?: 'contribution' | 'month_contribution' | 'nickname', isAsc?: boolean): Promise>; letterCountInfo(): Promise<{data: {firstLetterCounts: Record; lastLetterCounts: Record;}, error: null}|{data: null; error: PostgrestError}>; wordsByAdvancedQuery(input: advancedQueryType): Promise<{data: {word: string, nextWordCount: number}[], error: null} | {data: null; error: PostgrestError}>; + wordState(): Promise<{data: {firstLetterCounts: word_first_letter_counts[]; lastLetterCounts: word_last_letter_counts[];}, error: null}|{data: null; error: PostgrestError}>; } // delete 관련 타입 diff --git a/app/lib/supabase/SupabaseClientManager.ts b/app/lib/supabase/SupabaseClientManager.ts index 63f5a1f..ee69c85 100644 --- a/app/lib/supabase/SupabaseClientManager.ts +++ b/app/lib/supabase/SupabaseClientManager.ts @@ -623,6 +623,14 @@ class GetManager implements IGetManager { public async allUser(sortField?: 'contribution' | 'month_contribution' | 'nickname', isAsc?: boolean) { return await this.supabase.from('users').select('*').order(sortField ?? 'contribution', { ascending: isAsc ?? false }); } + public async wordState(){ + const {data: data1, error: error1} = await this.supabase.from('word_first_letter_counts').select('*'); + const {data: data2, error: error2} = await this.supabase.from('word_last_letter_counts').select('*'); + if (error1) return {data: null, error: error1}; + if (error2) return {data: null, error: error2}; + + return {data: {firstLetterCounts: data1, lastLetterCounts: data2}, error: null}; + } } diff --git a/app/word/page.tsx b/app/word/page.tsx index 9e509dc..41ad728 100644 --- a/app/word/page.tsx +++ b/app/word/page.tsx @@ -7,7 +7,8 @@ import { FileText, Clock, ChevronRight, - Database + Database, + BarChart3 } from "lucide-react"; export async function generateMetadata() { @@ -74,6 +75,14 @@ const features = [ color: "from-pink-500 to-rose-500", bgColor: "group-hover:bg-pink-50 dark:group-hover:bg-pink-950/20" }, + { + title: "단어 통계", + description: "글자별 단어 개수 통계를 확인합니다.", + link: "/word/stats", + icon: BarChart3, + color: "from-indigo-500 to-blue-500", + bgColor: "group-hover:bg-indigo-50 dark:group-hover:bg-indigo-950/20" + }, ]; export default function OpenDBHomePage() { @@ -94,7 +103,7 @@ export default function OpenDBHomePage() {
    {/* 기능 카드 그리드 */} -
    +
    {features.map((feature, idx) => { const IconComponent = feature.icon; diff --git a/app/word/search/hooks/useWordSearch.ts b/app/word/search/hooks/useWordSearch.ts index 0fe3ff6..ca7b3d1 100644 --- a/app/word/search/hooks/useWordSearch.ts +++ b/app/word/search/hooks/useWordSearch.ts @@ -1,9 +1,11 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; +import { useSearchParams } from 'next/navigation'; import { SCM } from '@/app/lib/supabaseClient'; import { advancedQueryType } from '@/app/types/type'; import { GameMode, SearchResult } from '../types'; export const useWordSearch = () => { + const searchParams = useSearchParams(); const [searchType, setSearchType] = useState<'simple' | 'advanced'>('simple'); const [mode, setMode] = useState('kor-start'); const [results, setResults] = useState([]); @@ -24,6 +26,59 @@ export const useWordSearch = () => { const [simpleQuery, setSimpleQuery] = useState(''); const [displayLimit, setDisplayLimit] = useState('100'); const [selectedTheme, setSelectedTheme] = useState<{id: number, name: string} | null>(null); + const [autoSearchTriggered, setAutoSearchTriggered] = useState(false); + + // URL 쿼리 파라미터 처리 + useEffect(() => { + const modeParam = searchParams.get('mode'); + const qParam = searchParams.get('q'); + + if (modeParam || qParam) { + // mode 파라미터 처리 + let targetMode: GameMode = 'kor-start'; + if (modeParam === 'f') { + targetMode = 'kor-start'; + } else if (modeParam === 'l') { + targetMode = 'kor-end'; + } else if (modeParam === 'k') { + targetMode = 'kung'; + } + + setMode(targetMode); + setSearchType('advanced'); + + // q 파라미터 처리 + if (qParam) { + setManner(''); // manner을 빈 문자열로 설정 + + if (targetMode === 'kor-start' || targetMode === 'kung') { + setStartLetter(qParam); + } else if (targetMode === 'kor-end') { + setEndLetter(qParam); + } + + // 쿵쿵따 모드인 경우 길이 설정 + if (targetMode === 'kung') { + setMinLength(3); + setMaxLength(3); + } + + // 자동 검색 트리거 + setAutoSearchTriggered(true); + } + } + }, [searchParams]); + + // 자동 검색 실행 + useEffect(() => { + if (autoSearchTriggered) { + setAutoSearchTriggered(false); + // 약간의 지연을 두어 상태가 모두 업데이트된 후 검색 실행 + setTimeout(() => { + handleSearch(); + }, 100); + } + }, [autoSearchTriggered]); const handleSearch = async () => { setLoading(true); diff --git a/app/word/search/page.tsx b/app/word/search/page.tsx index 6ea0bae..f48c5f0 100644 --- a/app/word/search/page.tsx +++ b/app/word/search/page.tsx @@ -1,3 +1,4 @@ +import { Suspense } from 'react'; import WordSearch from './WordSearch'; export async function generateMetadata() { @@ -16,7 +17,11 @@ export async function generateMetadata() { } const WordSearchPage = () => { - return ; + return ( + 로딩 중...
    }> + + + ); } export default WordSearchPage; \ No newline at end of file diff --git a/app/word/stats/WordStatsHome.tsx b/app/word/stats/WordStatsHome.tsx new file mode 100644 index 0000000..ef84f15 --- /dev/null +++ b/app/word/stats/WordStatsHome.tsx @@ -0,0 +1,462 @@ +"use client"; + +import { useEffect, useState, useMemo, useRef } from "react"; +import { SCM } from "@/app/lib/supabaseClient"; +import { BarChart3, TrendingUp, Loader2, AlertCircle, Search, ArrowUpDown } from "lucide-react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import type { Database } from "@/app/types/database.types"; +import Link from 'next/link'; + +type word_first_letter_counts = Database['public']['Tables']['word_first_letter_counts']['Row']; +type word_last_letter_counts = Database['public']['Tables']['word_last_letter_counts']['Row']; + +type ViewMode = 'first' | 'last' | 'len3'; +type SortField = 'letter' | 'k_count' | 'n_count'; +type SortOrder = 'asc' | 'desc'; +type CompareOperator = '=' | '>' | '<' | '>=' | '<='; + +export function WordStatsHome() { + const [firstLetterCounts, setFirstLetterCounts] = useState([]); + const [lastLetterCounts, setLastLetterCounts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // 필터 및 정렬 상태 + const [viewMode, setViewMode] = useState('first'); + const [searchLetter, setSearchLetter] = useState(''); + const [countFilter, setCountFilter] = useState(''); + const [compareOp, setCompareOp] = useState('>='); + const [sortField, setSortField] = useState('k_count'); + const [sortOrder, setSortOrder] = useState('desc'); + + // 가상 스크롤을 위한 ref + const parentRef = useRef(null); + + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + const { data, error } = await SCM.get().wordState(); + + if (error) { + setError(error.message); + return; + } + + if (data) { + setFirstLetterCounts(data.firstLetterCounts); + setLastLetterCounts(data.lastLetterCounts); + } + } catch (err) { + setError(err instanceof Error ? err.message : "데이터를 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + // 필터링 및 정렬 로직 + const filteredAndSortedData = useMemo(() => { + let data: Array<{ + letter: string; + k_count: number; + n_count: number; + k_updated: string | null; + n_updated: string | null; + }> = []; + + if (viewMode === 'first') { + data = firstLetterCounts.map(item => ({ + letter: item.first_letter, + k_count: item.k_count, + n_count: item.n_count, + k_updated: item.k_count_updated_at, + n_updated: item.n_count_updated_at, + })); + } else if (viewMode === 'last') { + data = lastLetterCounts.map(item => ({ + letter: item.last_letter, + k_count: item.k_count, + n_count: item.n_count, + k_updated: item.k_count_updated_at, + n_updated: item.n_count_updated_at, + })); + } else if (viewMode === 'len3') { + data = firstLetterCounts.map(item => ({ + letter: item.first_letter, + k_count: item.len3_k_count, + n_count: item.len3_n_count, + k_updated: item.len3_k_count_updated_at, + n_updated: item.len3_n_count_updated_at, + })); + } + + // 글자 검색 필터 + if (searchLetter.trim()) { + data = data.filter(item => item.letter.includes(searchLetter.trim())); + } + + // 카운트 필터 + if (countFilter.trim()) { + const filterValue = parseInt(countFilter.trim()); + if (!isNaN(filterValue)) { + data = data.filter(item => { + const maxCount = Math.max(item.k_count, item.n_count); + switch (compareOp) { + case '=': return maxCount === filterValue; + case '>': return maxCount > filterValue; + case '<': return maxCount < filterValue; + case '>=': return maxCount >= filterValue; + case '<=': return maxCount <= filterValue; + default: return true; + } + }); + } + } + + // 정렬 + data.sort((a, b) => { + let compareValue = 0; + + if (sortField === 'letter') { + compareValue = a.letter.localeCompare(b.letter, 'ko'); + } else if (sortField === 'k_count') { + compareValue = a.k_count - b.k_count; + } else if (sortField === 'n_count') { + compareValue = a.n_count - b.n_count; + } + + return sortOrder === 'asc' ? compareValue : -compareValue; + }); + + return data; + }, [firstLetterCounts, lastLetterCounts, viewMode, searchLetter, countFilter, compareOp, sortField, sortOrder]); + + // 가상 스크롤러 설정 + const virtualizer = useVirtualizer({ + count: filteredAndSortedData.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 140, // 각 아이템의 예상 높이 + overscan: 5, // 화면 밖에 미리 렌더링할 아이템 수 + }); + + const formatDate = (dateString: string | null) => { + if (!dateString) return '정보 없음'; + const date = new Date(dateString); + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + }; + + if (loading) { + return ( +
    +
    + +

    데이터를 불러오는 중...

    +
    +
    + ); + } + + if (error) { + return ( +
    +
    + +

    {error}

    +
    +
    + ); + } + + const maxCount = filteredAndSortedData.length > 0 + ? Math.max(...filteredAndSortedData.map(x => Math.max(x.k_count, x.n_count)), 1) + : 1; + + const getModeTitle = () => { + switch (viewMode) { + case 'first': return '첫 글자별 통계'; + case 'last': return '끝 글자별 통계'; + case 'len3': return '쿵쿵따 첫 글자별 통계'; + } + }; + + const getModeColor = () => { + switch (viewMode) { + case 'first': return 'from-indigo-500 to-purple-500'; + case 'last': return 'from-yellow-500 to-lime-500'; + case 'len3': return 'from-orange-500 to-red-500'; + } + }; + + return ( +
    +
    + {/* 헤더 */} +
    +
    + +

    + 단어 통계 +

    +
    +

    + 각 글자별 단어 개수 통계를 확인하세요 +

    +
    + + {/* 뷰 모드 선택 */} +
    + + + +
    + + {/* 필터 및 검색 */} +
    +
    + {/* 글자 검색 */} +
    + + setSearchLetter(e.target.value)} + placeholder="예: 가" + className="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent" + /> +
    + + {/* 카운트 필터 연산자 */} +
    + + +
    + + {/* 카운트 필터 값 */} +
    + + setCountFilter(e.target.value)} + placeholder="예: 100" + className="w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-indigo-500 focus:border-transparent" + /> +
    + + {/* 정렬 */} +
    + +
    + + +
    +
    +
    +
    + + {/* 통계 표시 */} +
    +
    +
    + +

    + {getModeTitle()} +

    +
    +
    + {filteredAndSortedData.length}개 항목 +
    +
    + +
    + {filteredAndSortedData.length === 0 ? ( +
    + 검색 결과가 없습니다. +
    + ) : ( +
    + {virtualizer.getVirtualItems().map((virtualRow) => { + const item = filteredAndSortedData[virtualRow.index]; + if (!item) return null; + + return ( +
    +
    +
    + + {item.letter} + +
    +
    +
    어인정O
    +
    + {item.k_count.toLocaleString()} +
    +
    + {formatDate(item.k_updated)} +
    +
    +
    +
    어인정X
    +
    + {item.n_count.toLocaleString()} +
    +
    + {formatDate(item.n_updated)} +
    +
    +
    +
    + + {/* 한방 진행 바 */} +
    +
    + 어인정O +
    +
    +
    +
    +
    + + {/* 일반 진행 바 */} +
    +
    + 어인정X +
    +
    +
    +
    +
    +
    +
    + ); + })} +
    + )} +
    +
    + + {/* 요약 통계 */} +
    +
    +

    총 글자 종류

    +

    + {filteredAndSortedData.length} +

    +
    +
    +

    총 어인정O 카운트 수

    +

    + {filteredAndSortedData.reduce((sum, item) => sum + item.k_count, 0).toLocaleString()} +

    +
    +
    +

    총 어인정X 카운트 수

    +

    + {filteredAndSortedData.reduce((sum, item) => sum + item.n_count, 0).toLocaleString()} +

    +
    +
    +
    +
    + ); +} diff --git a/app/word/stats/page.tsx b/app/word/stats/page.tsx new file mode 100644 index 0000000..6a14286 --- /dev/null +++ b/app/word/stats/page.tsx @@ -0,0 +1,20 @@ +import { WordStatsHome } from "./WordStatsHome"; + +export async function generateMetadata() { + return { + title: "끄코 유틸리티 - 단어 통계", + description: "끄코 유틸리티 - 단어 통계 페이지", + openGraph: { + title: "끄코 유틸리티 - 단어 통계", + description: "끄코 유틸리티 - 단어 통계 페이지", + type: "website", + url: "https://kkuko-utils.vercel.app/word/stats", + siteName: "끄코 유틸리티", + locale: "ko_KR", + }, + }; +} + +export default function WordStatsPage() { + return ; +} From 7533d6d898384aa0e00bd9c662b771d927e89cc1 Mon Sep 17 00:00:00 2001 From: JUNG TAEWON <153927840+hafskjfha@users.noreply.github.com> Date: Thu, 27 Nov 2025 00:54:00 +0000 Subject: [PATCH 14/20] =?UTF-8?q?feat:=20=EB=A6=AC=ED=94=8C=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20=EB=B6=84=EC=84=9D=EC=97=90=20=EB=8B=A8=EC=96=B4=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=EA=B7=B8=EB=9E=98=ED=94=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/replayParser.ts | 84 ++++++++++++++++++---- app/replay-analyzer/ReplayAnalyzerPage.tsx | 73 ++++++++++++++++++- app/types/replay.ts | 10 +-- 3 files changed, 144 insertions(+), 23 deletions(-) diff --git a/app/lib/replayParser.ts b/app/lib/replayParser.ts index 757a2b2..26bc880 100644 --- a/app/lib/replayParser.ts +++ b/app/lib/replayParser.ts @@ -11,7 +11,10 @@ type ParserError = { type ReturnModeParse = { words: string[]; wordAndThemes: { [key: string]: string[] }; - logs: { type: GameEventType | "turnHint"; time: number; userId: string; message: string; }[] + logs: { type: GameEventType | "turnHint"; time: number; userId?: string; message: string; }[]; + roundChains: { round: number; chains: { userId: string; word: string | null; isRoundEnd: boolean }[] }[]; + userMapping: { [userId: string]: number }; + mode: number; } export default class ReplayParser { @@ -56,7 +59,12 @@ export default class ReplayParser { const words: string[] = []; const wordAndThemes: { [key: string]: string[] } = {}; - const logs: { type: GameEventType; time: number; userId: string; message: string; }[] = []; + const logs: { type: GameEventType; time: number; userId?: string; message: string; }[] = []; + const roundChains: { round: number; chains: { userId: string; word: string | null; isRoundEnd: boolean }[] }[] = []; + const userMapping: { [userId: string]: number } = {}; + const userOrder: string[] = []; + let currentRound = -1; + let currentChain: { userId: string; word: string | null; isRoundEnd: boolean }[] = []; logs.push({ type: 'startGame', @@ -73,43 +81,61 @@ export default class ReplayParser { logs.push({ type: "chat", time: event.time, userId: eventData.from, message: eventData.value }); break; case "conn": - logs.push({ type: "conn", time: event.time, userId: eventData.user.profile.id, message: `${eventData.user.profile.id}님이 입장하셨습니다.` }); + logs.push({ type: "conn", time: event.time, userId: eventData.user.profile?.id, message: `${eventData.user.profile?.id}님이 입장하셨습니다.` }); break; case "disconn": - logs.push({ type: "disconn", time: event.time, userId: eventData.id, message: `${eventData.id}님이 퇴장하셨습니다.` }); + logs.push({ type: "disconn", time: event.time, userId: eventData?.id, message: `${eventData?.id}님이 퇴장하셨습니다.` }); break; case "disconnRoom": - logs.push({ type: "disconnRoom", time: event.time, userId: eventData.id, message: `${eventData.id}님이 퇴장하셨습니다.` }); + logs.push({ type: "disconnRoom", time: event.time, userId: eventData?.id, message: `${eventData?.id}님이 퇴장하셨습니다.` }); break; case "roundReady": if (!("char" in eventData) || !("mission" in eventData)) continue; - logs.push({ type: "roundReady", time: event.time, userId: eventData.profile.id, message: `${eventData.round}라운드 준비. 시작 글자: ${eventData.char}${eventData.mission ? `, 미션글자: ${eventData.mission}` : ""}` }); + logs.push({ type: "roundReady", time: event.time, userId: eventData.profile?.id, message: `${eventData.round}라운드 준비. 시작 글자: ${eventData.char}${eventData.mission ? `, 미션글자: ${eventData.mission}` : ""}` }); + if (currentChain.length > 0) { + roundChains.push({ round: currentRound, chains: currentChain }); + } + currentRound = eventData.round; + currentChain = []; break; case "turnStart": if (!("mission" in eventData)) continue; - logs.push({ type: "turnStart", time: event.time, userId: eventData.profile.id, message: `시작글자: ${eventData.char}${eventData.mission ? `, 미션글자: ${eventData.mission}` : ""}로 턴 시작` }); + logs.push({ type: "turnStart", time: event.time, userId: eventData.profile?.id, message: `시작글자: ${eventData.char}${eventData.mission ? `, 미션글자: ${eventData.mission}` : ""}로 턴 시작` }); break; case "turnEnd": // 명시적 타입 가드 사용 if ("ok" in eventData) { + const userId = eventData.profile?.id || ""; + // 첫 번째 라운드에서 유저 순서 기록 + if (currentRound === 1 && userId && !userOrder.includes(userId)) { + userOrder.push(userId); + userMapping[userId] = userOrder.length; + } + if (eventData.ok) { words.push(eventData.value); wordAndThemes[eventData.value] = [...new Set(eventData.theme.split(","))]; - logs.push({ type: "turnEnd", time: event.time, userId: eventData.profile.id, message: `"${eventData.value}"입력 성공. 얻은 점수: ${eventData.score}점 (미션 보너스: ${eventData.bonus ? eventData.bonus + "점" : "없음"})` }); + logs.push({ type: "turnEnd", time: event.time, userId: eventData.profile?.id, message: `"${eventData.value}"입력 성공. 얻은 점수: ${eventData.score}점 (미션 보너스: ${eventData.bonus ? eventData.bonus + "점" : "없음"})` }); + currentChain.push({ userId: eventData.profile?.id, word: eventData.value, isRoundEnd: false }); } else { if (!eventData.hint) continue; words.push(eventData.hint); - logs.push({ type: "turnEnd", time: event.time, userId: eventData.profile.id, message: `라운드 종료. 힌트: ${eventData.hint}. 잃은 점수: ${Math.abs(eventData.score)}점` }); + logs.push({ type: "turnEnd", time: event.time, userId: eventData.profile?.id, message: `라운드 종료. 힌트: ${eventData.hint}. 잃은 점수: ${Math.abs(eventData.score)}점` }); + currentChain.push({ userId: eventData.profile?.id, word: null, isRoundEnd: true }); } } break; case "turnError": - logs.push({ type: "turnError", time: event.time, userId: eventData.profile.id, message: `턴 넘기기 실패. 입력한 단어: ${eventData.value}, 에러코드: ${eventData.code} (${errorCode[eventData.code] ?? "알 수 없는 에러"})` }); + logs.push({ type: "turnError", time: event.time, userId: eventData.profile?.id, message: `턴 넘기기 실패. 입력한 단어: ${eventData.value}, 에러코드: ${eventData.code} (${errorCode[eventData.code] ?? "알 수 없는 에러"})` }); break; } } - return { data: { words: [...new Set(words)], wordAndThemes, logs }, error: null }; + if (currentChain.length > 0) { + roundChains.push({ round: currentRound, chains: currentChain }); + } + + return { data: { words: [...new Set(words)], wordAndThemes, logs, roundChains, userMapping, mode: this.parsedData.mode }, error: null }; } private jaquizModeAnalyze(): { data: ReturnModeParse, error: null } | { data: null, error: ParserError } { @@ -118,7 +144,10 @@ export default class ReplayParser { const words: string[] = []; const wordAndThemes: { [key: string]: string[] } = {}; - const logs: { type: GameEventType | "turnHint"; time: number; userId: string; message: string; }[] = []; + const logs: { type: GameEventType | "turnHint"; time: number; userId?: string; message: string; }[] = []; + const roundChains: { round: number; chains: { userId: string; word: string | null; isRoundEnd: boolean }[] }[] = []; + let currentRound = -1; + let currentChain: { userId: string; word: string | null; isRoundEnd: boolean }[] = []; logs.push({ type: 'startGame', @@ -147,6 +176,11 @@ export default class ReplayParser { // Mode4에서는 roundReady의 구조가 다름 if ("theme" in eventData) { logs.push({ type: "roundReady", time: event.time, userId: eventData.profile.id, message: `${eventData.round}라운드 준비. 주제: ${eventData.theme}` }); + if (currentChain.length > 0) { + roundChains.push({ round: currentRound, chains: currentChain }); + } + currentRound = eventData.round; + currentChain = []; } break; case "turnStart": @@ -166,6 +200,7 @@ export default class ReplayParser { if ("answer" in eventData) { words.push(eventData.answer); logs.push({ type: "turnEnd", time: event.time, userId: eventData.profile.id, message: `정답: ${eventData.answer}. ${eventData.score ? `얻은 점수: ${eventData.score}점` : ""} ${eventData.bonus ? `(보너스: ${eventData.bonus}점)` : ""}` }); + currentChain.push({ userId: eventData.profile.id, word: eventData.answer, isRoundEnd: false }); } break; case "turnError": @@ -174,7 +209,11 @@ export default class ReplayParser { } } - return { data: { words: [...new Set(words)], wordAndThemes, logs }, error: null }; + if (currentChain.length > 0) { + roundChains.push({ round: currentRound, chains: currentChain }); + } + + return { data: { words: [...new Set(words)], wordAndThemes, logs, roundChains, userMapping: {}, mode: this.parsedData.mode }, error: null }; } private handandaeModeAnalyze(): { data: ReturnModeParse, error: null} | { data: null, error: ParserError} { @@ -183,7 +222,10 @@ export default class ReplayParser { const words: string[] = []; const wordAndThemes: { [key: string]: string[] } = {}; - const logs: { type: GameEventType; time: number; userId: string; message: string; }[] = []; + const logs: { type: GameEventType; time: number; userId?: string; message: string; }[] = []; + const roundChains: { round: number; chains: { userId: string; word: string | null; isRoundEnd: boolean }[] }[] = []; + let currentRound = -1; + let currentChain: { userId: string; word: string | null; isRoundEnd: boolean }[] = []; logs.push({ type: 'startGame', @@ -212,6 +254,11 @@ export default class ReplayParser { // Mode5에서는 roundReady의 구조가 다름 if ("theme" in eventData) { logs.push({ type: "roundReady", time: event.time, userId: eventData.profile.id, message: `${eventData.round}라운드 준비. 주제: ${eventData.theme}` }); + if (currentChain.length > 0) { + roundChains.push({ round: currentRound, chains: currentChain }); + } + currentRound = eventData.round; + currentChain = []; } break; case "turnStart": @@ -227,6 +274,9 @@ export default class ReplayParser { words.push(eventData.value); wordAndThemes[eventData.value] = [...new Set(eventData.theme.split(","))]; logs.push({ type: "turnEnd", time: event.time, userId: "", message: `"${eventData.value}"입력 성공. 얻은 점수: ${eventData.score}점 (미션 보너스: ${eventData.bonus ? eventData.bonus + "점" : "없음"})` }); + currentChain.push({ userId: "", word: eventData.value, isRoundEnd: false }); + } else { + currentChain.push({ userId: "", word: null, isRoundEnd: true }); } } break; @@ -236,6 +286,10 @@ export default class ReplayParser { } } - return { data: { words: [...new Set(words)], wordAndThemes, logs }, error: null }; + if (currentChain.length > 0) { + roundChains.push({ round: currentRound, chains: currentChain }); + } + + return { data: { words: [...new Set(words)], wordAndThemes, logs, roundChains, userMapping: {}, mode: this.parsedData.mode }, error: null }; } } \ No newline at end of file diff --git a/app/replay-analyzer/ReplayAnalyzerPage.tsx b/app/replay-analyzer/ReplayAnalyzerPage.tsx index 057dbed..6715797 100644 --- a/app/replay-analyzer/ReplayAnalyzerPage.tsx +++ b/app/replay-analyzer/ReplayAnalyzerPage.tsx @@ -21,7 +21,10 @@ interface AnalyzedFile { status: FileStatus; words: string[]; wordAndThemes: { [key: string]: string[] }; - logs: { type: GameEventType | "turnHint"; time: number; userId: string; message: string; }[]; + logs: { type: GameEventType | "turnHint"; time: number; userId?: string; message: string; }[]; + roundChains: { round: number; chains: { userId: string; word: string | null; isRoundEnd: boolean }[] }[]; + userMapping: { [userId: string]: number }; + mode: number; error?: string; } @@ -111,6 +114,9 @@ export default function ReplayAnalyzerPage() { words: [], wordAndThemes: {}, logs: [], + roundChains: [], + userMapping: {}, + mode: 0, error: parseResult.error.name }); continue; @@ -126,6 +132,9 @@ export default function ReplayAnalyzerPage() { words: [], wordAndThemes: {}, logs: [], + roundChains: [], + userMapping: {}, + mode: 0, error: analyzeResult.error.name }); continue; @@ -137,7 +146,10 @@ export default function ReplayAnalyzerPage() { status: 'success', words: analyzeResult.data.words, wordAndThemes: analyzeResult.data.wordAndThemes, - logs: analyzeResult.data.logs + logs: analyzeResult.data.logs, + roundChains: analyzeResult.data.roundChains, + userMapping: analyzeResult.data.userMapping, + mode: analyzeResult.data.mode }); } catch (error) { @@ -148,6 +160,9 @@ export default function ReplayAnalyzerPage() { words: [], wordAndThemes: {}, logs: [], + roundChains: [], + userMapping: {}, + mode: 0, error: error instanceof Error ? error.message : '알 수 없는 오류' }); } @@ -296,8 +311,11 @@ export default function ReplayAnalyzerPage() { - + 단어 목록 ({file.words.length}개) + {file.mode !== 4 && file.mode !== 11 && ( + 연결 그래프 ({file.roundChains.length}라운드) + )} 게임 로그 ({file.logs.length}개) @@ -332,6 +350,55 @@ export default function ReplayAnalyzerPage() { + {file.mode !== 4 && file.mode !== 11 && ( + + +
    + {/* 유저 매핑 표시 */} + {Object.keys(file.userMapping).length > 0 && ( +
    +

    플레이어 번호

    +
    + {Object.entries(file.userMapping) + .sort((a, b) => a[1] - b[1]) + .map(([userId, userNum]) => ( + + {userNum}번: {userId} + + ))} +
    +
    + )} + + {file.roundChains.map((roundChain: { round: number; chains: { userId: string; word: string | null; isRoundEnd: boolean }[] }, roundIndex: number) => ( +
    +

    {roundChain.round}라운드

    +
    + {roundChain.chains.map((chain: { userId: string; word: string | null; isRoundEnd: boolean }, chainIndex: number) => ( + +
    + + {file.userMapping[chain.userId] || '?'} + + {chain.isRoundEnd ? ( + 라운드 종료 + ) : ( + {chain.word} + )} +
    + {chainIndex < roundChain.chains.length - 1 && ( + + )} +
    + ))} +
    +
    + ))} +
    +
    +
    + )} +
    diff --git a/app/types/replay.ts b/app/types/replay.ts index 778614c..19d5d22 100644 --- a/app/types/replay.ts +++ b/app/types/replay.ts @@ -4,7 +4,7 @@ type Player = { /* 플레이어 착용 의상 */ equip: PlayerEquip; /* 플레이어 고유id */ - id: string; + id?: string; /* 플레이어 닉네임 (이유는 모르나 #으로 시작함) */ nick: string; score: number; @@ -88,13 +88,13 @@ type GameEventData = | user: { data: { score: number } equip: PlayerEquip; - id: string; + id?: string; profile: GameEventProfile }; } | { type: "disconn"; - id: string; + id?: string; } | { type: "roundReady"; @@ -106,7 +106,7 @@ type GameEventData = | type: "turnStart"; char: string; mission: string | null; - id: string; + id?: string; profile: GameEventProfile; roundTime: number; turnTime: number; @@ -149,7 +149,7 @@ type GameEventData = | value: string; } | { type: "disconnRoom"; - id: string; + id?: string; } | { type: "okg"; count: number; From 138067c437c1b94b5cf1d16b906bd81ba7adea50 Mon Sep 17 00:00:00 2001 From: JUNG TAEWON <153927840+hafskjfha@users.noreply.github.com> Date: Thu, 27 Nov 2025 02:38:10 +0000 Subject: [PATCH 15/20] =?UTF-8?q?feat:=20=ED=8A=B9=EC=88=98=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=B2=98=EB=A6=AC=20=EC=9D=BC=EB=B6=80=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/supabase/ISupabaseClientManager.ts | 2 +- app/lib/supabase/SupabaseClientManager.ts | 33 +++++++++++++++++----- app/types/database.types.ts | 23 +++++++++++++++ app/word/search/[query]/WordInfoPage.tsx | 2 -- app/words-docs/[id]/DocsDataHome.tsx | 19 +++++++++---- app/words-docs/[id]/DocsDataPage.tsx | 19 +++++++++++-- app/words-docs/[id]/info/DocsInfoPage.tsx | 12 ++++++++ supabase/.temp/cli-latest | 2 +- 8 files changed, 93 insertions(+), 19 deletions(-) diff --git a/app/lib/supabase/ISupabaseClientManager.ts b/app/lib/supabase/ISupabaseClientManager.ts index b391d0f..57f10cc 100644 --- a/app/lib/supabase/ISupabaseClientManager.ts +++ b/app/lib/supabase/ISupabaseClientManager.ts @@ -48,7 +48,7 @@ export interface IGetManager{ allDocs(): Promise>; wordThemeByWordId(wordId: number): Promise>; docsInfoByDocsId(docsId: number): Promise> - docsWordCount({ name, duem, typez }: { name: string; duem: boolean; typez: "letter" | "theme";}): Promise<{count: number | null; error: PostgrestError | null;}> + docsWordCount({ name, duem, typez }: { name: string, duem: boolean, typez: "letter" | "theme" }|{name:number, duem:boolean, typez:"ect"}): Promise<{count: number | null; error: PostgrestError | null;}> docsVeiwRankByDocsId(docsId: number): Promise>; allThemes(): Promise> themeInfoByThemeName(name: string): Promise<{ data: theme | null; error: PostgrestError | null;}> diff --git a/app/lib/supabase/SupabaseClientManager.ts b/app/lib/supabase/SupabaseClientManager.ts index ee69c85..80a2fa3 100644 --- a/app/lib/supabase/SupabaseClientManager.ts +++ b/app/lib/supabase/SupabaseClientManager.ts @@ -98,7 +98,11 @@ class GetManager implements IGetManager { return await this.supabase.from('words').select('*,users(nickname)').eq('word', word).maybeSingle(); } public async allDocs() { - return await this.supabase.from('docs').select('*, users(*)').eq('is_hidden', false) + let q = this.supabase.from('docs').select('*, users(*)'); + if (process.env.NODE_ENV === 'production'){ + q = q.eq('is_hidden', false); + } + return await q; } public async wordThemeByWordId(wordId: number) { return await this.supabase.from('word_themes').select('words(*),themes(*)').eq('word_id', wordId); @@ -106,7 +110,7 @@ class GetManager implements IGetManager { public async docsInfoByDocsId(docsId: number) { return await this.supabase.from('docs').select('*,users(*)').eq('id', docsId).maybeSingle(); } - public async docsWordCount({ name, duem, typez }: { name: string, duem: boolean, typez: "letter" | "theme" }) { + public async docsWordCount({ name, duem, typez }: { name: string, duem: boolean, typez: "letter" | "theme" }|{name:number, duem:boolean, typez:"ect"}) { if (typez === "letter") { if (duem) { const { data, error } = await this.supabase.from('word_last_letter_counts').select('count').in('last_letter', reverDuemLaw(name[0])); @@ -116,11 +120,20 @@ class GetManager implements IGetManager { return { count: data?.count ?? 0, error } } } - else { + else if (typez === "theme") { const { data: themeData, error: themeDataError } = await this.themeInfoByThemeName(name) if (themeDataError || !themeData) return { count: 0, error: themeDataError } const { count, error } = await this.supabase.from('word_themes').select('*', { count: 'exact', head: true }).eq('theme_id', themeData.id); return { count, error } + } else if (typez === "ect") { + if (name === 201 || name === 202) { + const { count, error } = await this.supabase.from('words').select('*', { count: 'exact', head: true }).eq('k_canuse', true).gt('length', 8); + return { count, error }; + } else { + return { count: 0, error: { name: "unexcept", details: "", code: "", message: "", hint: "" } as PostgrestError } + } + } else { + return { count: 0, error: { name: "unexcept", details: "", code: "", message: "", hint: "" } as PostgrestError } } } public async docsVeiwRankByDocsId(docsId: number) { @@ -146,7 +159,7 @@ class GetManager implements IGetManager { public async docsWords({ name, duem, typez }: { name: string, duem: boolean, typez: "letter" | "theme" } | { name: number, duem: boolean, typez: "ect" }) { if (typez === "letter") { if (duem) { - const { data: wordsData, error: wordsError } = await this.supabase.from('words').select('*').in('last_letter', reverDuemLaw(name[0])).eq('k_canuse', true).neq('length', 1); + const { data: wordsData, error: wordsError } = await this.supabase.from('words').select('*').in('last_letter', [...new Set(...reverDuemLaw(name[0]), DuemLaw(name[0]))]).eq('k_canuse', true).neq('length', 1); if (wordsError) return { data: null, error: wordsError } let q = this.supabase.from('wait_words').select('word,requested_by,request_type'); for (const l of reverDuemLaw(name[0])) { @@ -167,7 +180,7 @@ class GetManager implements IGetManager { const { data: themeData, error: themeDataError } = await this.themeInfoByThemeName(name) if (themeDataError) return { data: null, error: themeDataError }; if (!themeData) return { data: { words: [], waitWords: [] }, error: null } - const { data: wordsData, error: wordsError } = await this.supabase.from('word_themes').select('words(*)').eq('theme_id', themeData.id); + const { data: wordsData, error: wordsError } = await this.supabase.rpc('get_words_by_theme', { theme_name: name }); const { data: waitWordsData1, error: waitWordsError1 } = await this.supabase.from('word_themes_wait').select('words(*),typez,req_by').eq('theme_id', themeData.id); const { data: waitAddWordsData2, error: waitAddWordsError2 } = await this.supabase.from('wait_word_themes').select('wait_words(word,requested_by,request_type)').eq('theme_id', themeData.id); const { data: waitDelWordsData, error: waitDelWordsError } = await this.supabase.rpc('get_delete_requests_by_themeid', { input_theme_id: themeData.id }) @@ -191,9 +204,15 @@ class GetManager implements IGetManager { }) waitWords.push(...waitDelWordsData) - return { data: { words: wordsData.filter(({ words: { word } }) => !waitWords.some(w => word === w.word)).map(({ words }) => words), waitWords }, error: null } + return { data: { words: wordsData.filter(({ word }) => !waitWords.some(w => word === w.word)), waitWords }, error: null } } else if (typez === "ect") { - // docsDataPage확인 + if (name === 201 || name === 202) { + const { data: wordsData, error: wordsError } = await this.supabase.from('words').select('*').eq('k_canuse', true).gt('length', 8); + if (wordsError) return { data: null, error: wordsError } + const { data: waitWordsData, error: waitWordsError } = await this.supabase.rpc('get_long_wait_words_data'); + if (waitWordsError) return { data: null, error: waitWordsError } + return { data: { words: wordsData.filter(({ word }) => !waitWordsData.some(w => word === w.word)), waitWords: waitWordsData }, error: null } + } return { data: null, error: { name: "unexcept", details: "", code: "", message: "", hint: "" } as PostgrestError } } else { return { data: null, error: { name: "unexcept", details: "", code: "", message: "", hint: "" } as PostgrestError } diff --git a/app/types/database.types.ts b/app/types/database.types.ts index d45f614..1944a14 100644 --- a/app/types/database.types.ts +++ b/app/types/database.types.ts @@ -792,7 +792,30 @@ export type Database = { word: string }[] } + get_long_wait_words_data: { + Args: never + Returns: { + request_type: Database["public"]["Enums"]["request_type_enum"] + requested_by: string + word: string + }[] + } get_user_monthly_rank: { Args: { uid: string }; Returns: number } + get_words_by_theme: { + Args: { theme_name: string } + Returns: { + added_at: string + added_by: string + chosungs: string + first_letter: string + id: number + k_canuse: boolean + last_letter: string + length: number + noin_canuse: boolean + word: string + }[] + } get_words_with_themes: { Args: { words_input: string[] } Returns: { diff --git a/app/word/search/[query]/WordInfoPage.tsx b/app/word/search/[query]/WordInfoPage.tsx index 746ec06..f8e6d5b 100644 --- a/app/word/search/[query]/WordInfoPage.tsx +++ b/app/word/search/[query]/WordInfoPage.tsx @@ -3,12 +3,10 @@ import WordInfo from './WordInfo'; import { SCM } from '@/app/lib/supabaseClient'; import ErrorPage from '@/app/components/ErrorPage'; import { useEffect, useState } from 'react'; -import NotFound from '@/app/not-found-client'; import type { PostgrestError } from '@supabase/supabase-js'; import { calculateKoreanInitials, count } from '@/app/lib/lib'; import LoadingPage, {useLoadingState } from '@/app/components/LoadingPage'; import axios from 'axios'; -import DuemRaw,{ reverDuemLaw } from '@/app/lib/DuemLaw'; import { useRouter } from 'next/navigation'; import { WordInfoProps } from './WordInfo'; diff --git a/app/words-docs/[id]/DocsDataHome.tsx b/app/words-docs/[id]/DocsDataHome.tsx index c1b4516..99f6ea9 100644 --- a/app/words-docs/[id]/DocsDataHome.tsx +++ b/app/words-docs/[id]/DocsDataHome.tsx @@ -118,10 +118,18 @@ const DocsDataHome = ({ id, data, metaData, starCount }: DocsPageProp) => { } }); } else { - data.forEach((item) => { - const firstSyllable = item.word[0].toLowerCase(); - grouped.get(firstSyllable).push(item); - }); + if (metaData.title.includes("앞말잇기")) { + data.forEach((item) => { + const firstSyllable = item.word[item.word.length - 1].toLowerCase(); + grouped.get(firstSyllable).push(item); + }); + } else { + data.forEach((item) => { + const firstSyllable = item.word[0].toLowerCase(); + grouped.get(firstSyllable).push(item); + }); + } + } return grouped; @@ -138,6 +146,7 @@ const DocsDataHome = ({ id, data, metaData, starCount }: DocsPageProp) => { return m2gr.get(char).length + m1gr.get(char).length > 0; }).map(char => `${char}`); } else { + if (metaData.title.includes("앞말잇기")) return [...new Set(data.map((v) => v.word[v.word.length - 1]))].sort((a, b) => a.localeCompare(b, "ko")); return [...new Set(data.map((v) => v.word[0]))].sort((a, b) => a.localeCompare(b, "ko")); } }; @@ -475,7 +484,7 @@ const DocsDataHome = ({ id, data, metaData, starCount }: DocsPageProp) => { title={item.title} initialData={item.data || []} isMission={activeTab === "mission"} - isLong={activeTab==="long"} + isLong={activeTab==="long" || metaData.title.includes("긴단어")} />
    diff --git a/app/words-docs/[id]/DocsDataPage.tsx b/app/words-docs/[id]/DocsDataPage.tsx index bb43fe5..991aab5 100644 --- a/app/words-docs/[id]/DocsDataPage.tsx +++ b/app/words-docs/[id]/DocsDataPage.tsx @@ -80,9 +80,22 @@ export default function DocsDataPage({id}:{id:number}){ } else{ - setIsNotFound(true); - // 나중에 특수 문서로 바꿀예정 - 기타문서 딱히 필요없어서 - // todo - https://github.com/hafskjfha/kkuko-utils/issues/76 + await new Promise(resolve => setTimeout(resolve, 1)); + updateLoadingState(30, "문서에 들어간 단어 정보 가져오는 중..."); + const {data, error} = await SCM.get().docsWords({name: docsData.id, duem: docsData.duem, typez: "ect"}); + if (error) return makeError(error); + if (data===null) return setIsNotFound(true); + const {words, waitWords} = data; + + await new Promise(resolve => setTimeout(resolve, 1)); + updateLoadingState(70, "데이터를 가공중..."); + + const wordsData = [ ...words.map(({word})=>({ word, status: "ok" as const, maker: undefined })), ...waitWords.map(({word, requested_by, request_type})=>({word, status: request_type, maker: requested_by ?? undefined})) ]; + const p = {title: docsData.name, lastUpdate: docsData.last_update, typez: docsData.typez} + setWordsData({words: wordsData, metadata: p, starCount:docsStarData.map(({user_id})=>user_id)}); + + await SCM.update().docView(docsData.id); + updateLoadingState(100, "완료!"); return; } } diff --git a/app/words-docs/[id]/info/DocsInfoPage.tsx b/app/words-docs/[id]/info/DocsInfoPage.tsx index 9d84fc4..b3c2922 100644 --- a/app/words-docs/[id]/info/DocsInfoPage.tsx +++ b/app/words-docs/[id]/info/DocsInfoPage.tsx @@ -77,6 +77,18 @@ export default function DocsInfoPage({ id }: { id: number }) { return; } else{ + if (docsData.id === 201 || docsData.id === 202){ + updateLoadingState(50,"문서의 단어 정보 가져오는 중..."); + const {count: ectWordsData1, error: ectWordsError1} = await SCM.get().docsWordCount({name: docsData.id, duem: docsData.duem, typez: "ect"}); + if (ectWordsError1){ return hanldeError(ectWordsError1); } + const {data, error} = await SCM.get().docsVeiwRankByDocsId(docsData.id); + if (error){ return hanldeError(error); } + + updateLoadingState(90,"데이터 가공중..."); + setDocsInfoData({metadata:docsData, wordsCount: ectWordsData1 ?? -1, rank: data, starCount:docsStarData}); + updateLoadingState(100,"완료!"); + return; + } return setIsNotFound(true); } }; diff --git a/supabase/.temp/cli-latest b/supabase/.temp/cli-latest index 8c1209b..7a78572 100644 --- a/supabase/.temp/cli-latest +++ b/supabase/.temp/cli-latest @@ -1 +1 @@ -v2.62.5 \ No newline at end of file +v2.62.10 \ No newline at end of file From 7069caa752bee57a813bd97dc4f28352cc002a29 Mon Sep 17 00:00:00 2001 From: JUNG TAEWON <153927840+hafskjfha@users.noreply.github.com> Date: Thu, 27 Nov 2025 02:47:55 +0000 Subject: [PATCH 16/20] =?UTF-8?q?fix:=20=EA=B2=80=EC=83=89=20=EC=95=85?= =?UTF-8?q?=EC=9A=A9=EC=9D=84=20=EB=B0=A9=EC=A7=80=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?=EC=9C=84=ED=95=B4=20=EA=B2=B0=EA=B3=BC=EC=B0=BD=EC=97=90=20?= =?UTF-8?q?=EA=B0=80=EB=A6=BC=EB=A7=89=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/word/search/components/SearchResults.tsx | 79 +++++++++++++++++++- 1 file changed, 76 insertions(+), 3 deletions(-) diff --git a/app/word/search/components/SearchResults.tsx b/app/word/search/components/SearchResults.tsx index ce90924..c4c1077 100644 --- a/app/word/search/components/SearchResults.tsx +++ b/app/word/search/components/SearchResults.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { useRef, useState, useEffect } from 'react'; import Link from 'next/link'; import { useVirtualizer } from '@tanstack/react-virtual'; import { BookOpen, ArrowRight, Search, Loader2 } from 'lucide-react'; @@ -23,6 +23,66 @@ export default function SearchResults({ mode }: SearchResultsProps) { const parentRef = useRef(null); + const [showOverlay, setShowOverlay] = useState(false); + const hideTimeoutRef = useRef(null); + + useEffect(() => { + const handleVisibilityChange = () => { + if (document.hidden) { + // 탭이 비활성화되면 즉시 가림막 표시 + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + setShowOverlay(true); + } else { + // 탭이 활성화되면 0.5초 후 가림막 제거 + hideTimeoutRef.current = setTimeout(() => { + setShowOverlay(false); + }, 500); + } + }; + + const handleBlur = () => { + // 윈도우가 포커스를 잃으면 가림막 표시 + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + setShowOverlay(true); + }; + + const handleFocus = () => { + // 윈도우가 포커스를 얻으면 0.5초 후 가림막 제거 + hideTimeoutRef.current = setTimeout(() => { + setShowOverlay(false); + }, 500); + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('blur', handleBlur); + window.addEventListener('focus', handleFocus); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('blur', handleBlur); + window.removeEventListener('focus', handleFocus); + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + } + }; + }, []); + + // 가림막이 표시될 때 스크롤 비활성화 + useEffect(() => { + if (showOverlay && parentRef.current) { + const scrollElement = parentRef.current; + scrollElement.style.overflow = 'hidden'; + } else if (parentRef.current) { + const scrollElement = parentRef.current; + scrollElement.style.overflow = ''; + } + }, [showOverlay]); const virtualizer = useVirtualizer({ count: results.length, @@ -68,9 +128,22 @@ export default function SearchResults({
    + {/* 가림막 오버레이 */} + {showOverlay && ( +
    +
    + + 로딩 중... +
    +
    + )} + {!searchPerformed ? (
    From 9bcb9ec40675083fce5cc87e391bf7f1d75f110c Mon Sep 17 00:00:00 2001 From: JUNG TAEWON <153927840+hafskjfha@users.noreply.github.com> Date: Thu, 27 Nov 2025 06:04:36 +0000 Subject: [PATCH 17/20] =?UTF-8?q?fix:=20=ED=8C=8C=ED=8A=B8=EB=84=88?= =?UTF-8?q?=EC=8B=AD=20=ED=91=9C=EC=8B=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/footer.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/app/footer.tsx b/app/footer.tsx index 87cf9ef..6a57060 100644 --- a/app/footer.tsx +++ b/app/footer.tsx @@ -13,13 +13,27 @@ const Footer = () => {
    {/* 브랜드 섹션 */} - {/* 링크 섹션 */}
    From e1582886484487b21434c720195cd9a71b70583b Mon Sep 17 00:00:00 2001 From: JUNG TAEWON <153927840+hafskjfha@users.noreply.github.com> Date: Thu, 27 Nov 2025 06:09:50 +0000 Subject: [PATCH 18/20] =?UTF-8?q?chore:=20=EB=A9=94=ED=83=80=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/word/search/page.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/word/search/page.tsx b/app/word/search/page.tsx index f48c5f0..409d2a5 100644 --- a/app/word/search/page.tsx +++ b/app/word/search/page.tsx @@ -4,10 +4,11 @@ import WordSearch from './WordSearch'; export async function generateMetadata() { return { title: "끄코 유틸리티 - 오픈DB 단어검색", - description: `끄코 유틸리티 - 오픈DB 단어 검색 홈`, + description: `끄투코리아 오픈DB 단어 검색`, + keywords: ["끄투코리아", "단어검색", "검색기", "끄투코리아 단어", "끄코 검색기"], openGraph: { title: "끄코 유틸리티 - 오픈DB 단어검색", - description: "끄코 유틸리티 - 오픈DB 단어 검색 홈", + description: "끄투코리아 오픈DB 단어 검색", type: "website", url: "https://kkuko-utils.vercel.app/word/search", siteName: "끄코 유틸리티", From 3d94eac5076873f27a6e6ab5894f8c1ff60cfd67 Mon Sep 17 00:00:00 2001 From: JUNG TAEWON <153927840+hafskjfha@users.noreply.github.com> Date: Thu, 27 Nov 2025 06:26:05 +0000 Subject: [PATCH 19/20] =?UTF-8?q?chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=84=EC=8B=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __tests__/admin/wordsCount.test.ts | 84 ------------------------------ 1 file changed, 84 deletions(-) delete mode 100644 __tests__/admin/wordsCount.test.ts diff --git a/__tests__/admin/wordsCount.test.ts b/__tests__/admin/wordsCount.test.ts deleted file mode 100644 index 4ad4d75..0000000 --- a/__tests__/admin/wordsCount.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { SupabaseClientManager } from '@/app/lib/supabase/SupabaseClientManager'; -import { createClient } from '@supabase/supabase-js'; - -// Mock Supabase client -const mockSupabase = { - from: jest.fn().mockReturnThis(), - select: jest.fn().mockReturnThis(), -} as any; - -// Mock createClient -jest.mock('@supabase/supabase-js', () => ({ - createClient: jest.fn(() => mockSupabase), -})); - -describe('SupabaseClientManager - wordsCount', () => { - let scm: SupabaseClientManager; - - beforeEach(() => { - jest.clearAllMocks(); - scm = new SupabaseClientManager(mockSupabase); - }); - - it('should use word_last_letter_counts table to get total word count', async () => { - const mockData = [ - { count: 100 }, - { count: 200 }, - { count: 50 }, - { count: 150 }, - ]; - - mockSupabase.select.mockResolvedValue({ - data: mockData, - error: null, - }); - - const result = await scm.get().wordsCount(); - - // Verify it uses the correct table and column - expect(mockSupabase.from).toHaveBeenCalledWith('word_last_letter_counts'); - expect(mockSupabase.select).toHaveBeenCalledWith('count'); - - // Verify it returns the sum of all counts - expect(result.count).toBe(500); // 100 + 200 + 50 + 150 - expect(result.error).toBeNull(); - }); - - it('should handle database error correctly', async () => { - const mockError = { message: 'Database error' }; - - mockSupabase.select.mockResolvedValue({ - data: null, - error: mockError, - }); - - const result = await scm.get().wordsCount(); - - expect(result.count).toBeNull(); - expect(result.error).toBe(mockError); - }); - - it('should handle empty data array', async () => { - mockSupabase.select.mockResolvedValue({ - data: [], - error: null, - }); - - const result = await scm.get().wordsCount(); - - expect(result.count).toBe(0); - expect(result.error).toBeNull(); - }); - - it('should handle null data', async () => { - mockSupabase.select.mockResolvedValue({ - data: null, - error: null, - }); - - const result = await scm.get().wordsCount(); - - expect(result.count).toBe(0); - expect(result.error).toBeNull(); - }); -}); \ No newline at end of file From cf797601e636b1412a28e203f8cc5602ec5f4d17 Mon Sep 17 00:00:00 2001 From: JUNG TAEWON <153927840+hafskjfha@users.noreply.github.com> Date: Thu, 27 Nov 2025 06:32:37 +0000 Subject: [PATCH 20/20] =?UTF-8?q?fix:=20pr=20=EC=A0=9C=EC=95=88=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/request-words/AdminRequestHome.tsx | 2 +- app/lib/supabase/SupabaseClientManager.ts | 3 +-- .../extract/korean-mission-b/KoreanMissionB.tsx | 6 +++--- app/word/search/[query]/WordInfoPage.tsx | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app/admin/request-words/AdminRequestHome.tsx b/app/admin/request-words/AdminRequestHome.tsx index 6eca9b9..f60408c 100644 --- a/app/admin/request-words/AdminRequestHome.tsx +++ b/app/admin/request-words/AdminRequestHome.tsx @@ -686,7 +686,7 @@ export default function AdminHome({ requestDatas, refreshFn }: { requestDatas: W if (currentThemes.has(theme.theme_id)) { currentThemes.delete(theme.theme_id); if (currentThemes.size === 0) { - toggleRequest(request.id) + toggleRequest(request.id); } } else { currentThemes.add(theme.theme_id); diff --git a/app/lib/supabase/SupabaseClientManager.ts b/app/lib/supabase/SupabaseClientManager.ts index 80a2fa3..480e82d 100644 --- a/app/lib/supabase/SupabaseClientManager.ts +++ b/app/lib/supabase/SupabaseClientManager.ts @@ -5,7 +5,6 @@ import type { addWordQueryType, addWordThemeQueryType, DocsLogData, WordLogData, import DuemLaw, { reverDuemLaw } from '../DuemLaw'; import { sum } from 'es-toolkit'; import { StorageError } from '@supabase/storage-js'; -import { count } from '../lib'; const CACHE_DURATION = 10 * 60 * 1000; @@ -159,7 +158,7 @@ class GetManager implements IGetManager { public async docsWords({ name, duem, typez }: { name: string, duem: boolean, typez: "letter" | "theme" } | { name: number, duem: boolean, typez: "ect" }) { if (typez === "letter") { if (duem) { - const { data: wordsData, error: wordsError } = await this.supabase.from('words').select('*').in('last_letter', [...new Set(...reverDuemLaw(name[0]), DuemLaw(name[0]))]).eq('k_canuse', true).neq('length', 1); + const { data: wordsData, error: wordsError } = await this.supabase.from('words').select('*').in('last_letter', [...new Set([...reverDuemLaw(name[0]), DuemLaw(name[0])])]).eq('k_canuse', true).neq('length', 1); if (wordsError) return { data: null, error: wordsError } let q = this.supabase.from('wait_words').select('word,requested_by,request_type'); for (const l of reverDuemLaw(name[0])) { diff --git a/app/manager-tool/extract/korean-mission-b/KoreanMissionB.tsx b/app/manager-tool/extract/korean-mission-b/KoreanMissionB.tsx index ad30ca5..09afeea 100644 --- a/app/manager-tool/extract/korean-mission-b/KoreanMissionB.tsx +++ b/app/manager-tool/extract/korean-mission-b/KoreanMissionB.tsx @@ -34,7 +34,7 @@ const f = (word: string) => { return r; } -function count(text: string, target: string): number { +function countMissionChars(text: string, target: string): number { const escapedTarget = target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(escapedTarget, 'g'); const matches = text.match(regex); @@ -89,7 +89,7 @@ const WordExtractorApp = () => { if (!word.trim()) continue; const firstChar = word[0]; for (const m of MISSION_LETTERS){ - const missionCount = count(word, m); + const missionCount = countMissionChars(word, m); if (missionCount === 0) continue; const k = missionWordsMap.get(firstChar).get(m); if (k.count === missionCount) { @@ -127,7 +127,7 @@ const WordExtractorApp = () => { } for (const [startChar, missionMap] of missionWordsMap.sortedEntries()){ - result.push(`=[${startChar}]=`) + result.push(`=[${startChar}]=`); for (const m of MISSION_LETTERS){ if (missionMap.get(m).words.length === 0) continue; result.push(`-${m}-`); diff --git a/app/word/search/[query]/WordInfoPage.tsx b/app/word/search/[query]/WordInfoPage.tsx index f8e6d5b..9d8401e 100644 --- a/app/word/search/[query]/WordInfoPage.tsx +++ b/app/word/search/[query]/WordInfoPage.tsx @@ -263,7 +263,7 @@ export default function WordInfoPage({ query }: { query: string }) { }, [query]); - if (isNotFound) { goTo404(); } + if (isNotFound) { goTo404(); return null; } if (loadingState.isLoading) { return }