diff --git a/i18nexus.config.json b/i18nexus.config.json index de28766..e1f7106 100644 --- a/i18nexus.config.json +++ b/i18nexus.config.json @@ -3,8 +3,8 @@ "en", "ko" ], - "defaultLanguage": "en", - "localesDir": "./locales", + "defaultLanguage": "ko", + "localesDir": "./src/shared/i18n", "sourcePattern": "src/**/*.{js,jsx,ts,tsx}", "translationImportSource": "i18nexus", "googleSheets": { diff --git a/index.html b/index.html index ab97965..d95af67 100644 --- a/index.html +++ b/index.html @@ -40,30 +40,41 @@ + + + - Q-Asker: PDF/PPT 파일로 무료 AI 퀴즈 생성 + Q-Asker: PDF, PPT, Word로 무료 AI 퀴즈 생성 + + + + + - + + + @@ -71,47 +82,58 @@ property="og:image" content="https://www.q-asker.com/background.png" /> + + + + + - + + + - - + + + diff --git a/locales/en.json b/locales/en.json deleted file mode 100644 index faf8073..0000000 --- a/locales/en.json +++ /dev/null @@ -1,255 +0,0 @@ -{ - "\"문제 풀기\" 버튼으로": "With the \"Solve Quiz\" button", - "• 생성된 퀴즈는": "• Generated quizzes are", - "• 중요한 퀴즈는 생성 후 24시간 내에 완료하여 기록을\n 남겨두시기 바랍니다": "• Please complete important quizzes within 24 hours of generation to save a record.", - "• 퀴즈 기록은 최대": "• Quiz history is saved for a maximum of", - "=== 퀴즈 완료 데이터 저장 ===": "=== Save Quiz Completion Data ===", - "=== 퀴즈 통계 정보 ===": "=== Quiz Statistics Information ===", - "=== 퀴즈 히스토리 전체 데이터 ===": "=== All Quiz History Data ===", - "=== 해설 페이지 이동 시작 ===": "=== Start Navigation to Explanation Page ===", - "⏰ 자료 보호": "⏰ Data Protection", - "✕ 파일 삭제": "✕ Delete File", - "❌ 오답만": "❌ Incorrect Only", - "🌟 Q-Asker를 신뢰할 수 있는 이유": "🌟 Why You Can Trust Q-Asker", - "💡 AI 퀴즈 활용 200% 팁": "💡 200% Tips for Using AI Quiz", - "📄 관련 슬라이드": "📄 Related Slide", - "📈 단계별 풀어보기": "📈 Solve Step-by-Step", - "📋 명확한 문제 생성 기준": "📋 Clear Question Generation Criteria", - "📚 참조 페이지": "📚 Reference Page", - "📝 PDF/PPT로 AI 퀴즈 만들기 6단계 가이드": "📝 6-Step Guide to Making AI Quizzes with PDF/PPT", - "📞 문의 및 피드백": "📞 Contact & Feedback", - "🔄 복습 퀴즈": "🔄 Review Quiz", - "🙋‍♀️ 자주 묻는 질문 (FAQ)": "🙋‍♀️ Frequently Asked Questions (FAQ)", - "🚨 꼭 읽어주세요: 주의사항": "🚨 Please Read: Precautions", - "🚨파일은 상업적 목적, AI 학습 목적으로 사용되지 않습니다.": "🚨Files are not used for commercial purposes or AI training.", - "1. Easy 단계를 통해 핵심 개념을 암기하세요.": "1. Memorize core concepts through the Easy step.", - "1단계: 파일 업로드": "Step 1: File Upload", - "2. Normal 단계를 통해 간단한 맥락에 적용하세요.": "2. Apply to simple contexts through the Normal step.", - "20개": "20 items", - "24시간 후 서버에서 자동 삭제": "Automatically deleted from the server after 24 hours", - "24시간 후 자동 삭제되며 별도로 저장, 공유되지 않습니다.": "Automatically deleted after 24 hours and is not saved or shared separately.", - "2단계: 퀴즈 옵션 설정": "Step 2: Set Quiz Options", - "3. Hard 단계를 통해 깊은 추론을 요구하는 문제를 풀어보세요.": "3. Solve problems requiring deep reasoning through the Hard step.", - "3단계: AI 문제 생성": "Step 3: AI Question Generation", - "4단계: 퀴즈 풀기": "Step 4: Solve Quiz", - "5 ~ 25개 (5개 단위)": "5 ~ 25 (in increments of 5)", - "5단계: 결과 및 해설 확인": "Step 5: Check Results & Explanations", - "6단계: 퀴즈 기록 관리": "Step 6: Manage Quiz History", - "AI 분석: ": "AI Analysis: ", - "AI 상세 해설 보기": "View Detailed AI Explanation", - "AI 퀴즈 생성": "Generate AI Quiz", - "AI 한계점:": "AI Limitations:", - "AI는 높은 정확도로 문서를 분석하지만, 100% 완벽하지 않을 수 있습니다. 생성된 문제는 학습 참고용이며, 중요한 정보는 반드시 원본과 교차 확인해주세요.": "AI analyzes documents with high accuracy, but it may not be 100% perfect. Generated questions are for learning reference, and important information must be cross-checked with the original.", - "Easy: 순수 암기나 단순 이해를 묻는 문제": "Easy: Questions asking for pure memorization or simple understanding", - "Hard: 한 단계 더 깊은 추론, 문제 해결, 자료 해석 등을 요구": "Hard: Requires deeper reasoning, problem-solving, data interpretation, etc.", - "Normal: 주어진 개념을 간단한 맥락에 적용하거나 비교·분석하게 하는 문제": "Normal: Questions that apply given concepts to simple contexts or require comparison/analysis", - "OCR 변환하기": "Convert OCR", - "OX 퀴즈": "True/False Quiz", - "PDF 로딩 중...": "Loading PDF...", - "PDF, PPT 공부 자료로 퀴즈를 만들어 보세요. 핵심 개념을 빠르게 암기하고 시험 대비에 효과적입니다.": "Create quizzes from your PDF, PPT study materials. It's effective for quickly memorizing core concepts and preparing for exams.", - "Q-Asker 사용 중 궁금한 점이나 개선 아이디어가 있으시면 언제든지 알려주세요! 더 좋은": "If you have any questions or ideas for improvement while using Q-Asker, please let us know anytime! A better", - "Q-Asker 이메일 문의": "Q-Asker Email Inquiry", - "Q-Asker: PDF/PPT 파일로 무료 AI 퀴즈 생성": "Q-Asker: Free AI Quiz Generation from PDF/PPT Files", - "Q. AI가 만든 퀴즈의 정확도는 어느 정도인가요?": "Q. What is the accuracy of the AI-generated quizzes?", - "Q. Q-Asker는 정말 무료인가요?": "Q. Is Q-Asker really free?", - "Q. 업로드한 제 파일은 안전하게 관리되나요?": "Q. Are my uploaded files managed securely?", - "Q. 이미지로 된 PDF 파일도 퀴즈로 만들 수 있나요?": "Q. Can I make quizzes from image-based PDF files?", - "Webb's Dok 이론에 기반한 단계별 풀어보기 기능을 활용해 한 단계씩 순서대로 풀어보세요.": "Utilize the step-by-step solving feature based on Webb's DOK theory to solve one step at a time.", - "Webb's dok 이론에 기반한 문제 생성 기준을 통해 문제를 생성합니다.": "Generates questions based on question generation criteria based on Webb's DOK theory.", - "가지고 계신 학습 자료(PDF, PPT)로 AI 퀴즈를 만드는 가장 쉬운 방법을 알려드립니다.": "Here is the easiest way to create AI quizzes from your study materials (PDF, PPT).", - "개": "items", - "객관식": "Multiple Choice", - "걸린 시간": "Time Taken", - "검토": "Review", - "검토 기능: ": "Review Feature: ", - "검토할 문제:": "Questions to Review:", - "결과 확인: ": "Check Results: ", - "구글 폼 링크": "Google Form Link", - "구글 폼:": "Google Form:", - "기록 삭제 실패:": "Failed to Delete History:", - "기록 삭제:": "Delete History:", - "기록 삭제에 실패했습니다.": "Failed to delete history.", - "기록을 불러오는 중...": "Loading history...", - "기록을 불러오는데 실패했습니다.": "Failed to load history.", - "기록이 삭제되었습니다.": "History has been deleted.", - "까지 자동으로 저장됩니다": "is automatically saved until", - "나중에 다시 볼 문제에 체크 표시": "Check questions to review later", - "난이도: ": "Difficulty: ", - "내 퀴즈 기록": "My Quiz History", - "네, PDF/PPT 기반 AI 퀴즈 생성은 현재 완전 무료입니다. 별도의 회원가입 없이 누구나 자유롭게 이용할 수 있습니다.": "Yes, AI quiz generation based on PDF/PPT is currently completely free. Anyone can use it freely without separate registration.", - "네. 업로드된 파일은 퀴즈 생성을 위해서만 일시적으로 사용되며, 24시간 뒤에 삭제됩니다.": "Yes. Uploaded files are used temporarily only for quiz generation and are deleted after 24 hours.", - "네비게이션: ": "Navigation: ", - "다른 문제 생성": "Generate Other Questions", - "다른 파일 넣기": "Insert Another File", - "다시 풀기": "Solve Again", - "다음": "Next", - "답변한 문제:": "Answered Questions:", - "더 많은 페이지 로딩 중... (": "Loading more pages... (", - "도움말 보기": "View Help", - "되어 해설을 볼 수\n 없게 됩니다": "and you will not be able to see the explanations", - "또는": "or", - "로딩 중…": "Loading…", - "로딩...": "Loading...", - "마지막 문제입니다.": "This is the last question.", - "만든 퀴즈가 퀴즈 기록에 자동 저장": "Created quizzes are automatically saved to quiz history", - "메뉴": "Menu", - "모든 기록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.": "Are you sure you want to delete all history? This action cannot be undone.", - "모든 기록이 삭제되었습니다.": "All history has been deleted.", - "모든 자료는 업로드 이후 24시간 뒤에 삭제됩니다": "All data is deleted 24 hours after upload", - "문서 미리보기": "Document Preview", - "문서를 분석하고 문제를 생성하려면 아래 버튼을 클릭하세요.": "Click the button below to analyze the document and generate questions.", - "문의 및 피드백": "Contact & Feedback", - "문제": "Question", - "문제 개수:": "Number of Questions:", - "문제 단계 설정하기": "Set Question Level", - "문제 로딩 중…": "Loading question…", - "문제 생성 중...": "Generating questions...", - "문제 생성하기": "Generate Questions", - "문제 수": "Number of Questions", - "문제 수량:": "Question Quantity:", - "문제 수량: ": "Question Quantity: ", - "문제 풀기": "Solve Quiz", - "문제 풀이: ": "Quiz Solving: ", - "문제별 자세한 설명과 참조한 페이지 미리보기 제공": "Provides detailed explanations for each question and a preview of the referenced page", - "문제셋 ID:": "Question Set ID:", - "미리보기 및 페이지 선택": "Preview and Page Selection", - "미선택": "Not selected", - "미완료": "Incomplete", - "반복 학습: ": "Repetitive Learning: ", - "번:": "No.:", - "보통 10초 ~ 30초 (문서 길이에 따라 다름)": "Usually 10s ~ 30s (depending on document length)", - "복습: ": "Review: ", - "빈칸 채우기(Easy), OX(Normal), 객관식(Hard) 중 선택": "Choose from Fill-in-the-blank (Easy), True/False (Normal), Multiple Choice (Hard)", - "사용자 지정": "Custom", - "삭제": "Delete", - "삭제된 퀴즈 기록은 복구할 수 없으니 신중하게 결정해주세요.": "Deleted quiz history cannot be recovered, so please decide carefully.", - "상세 해설": "Detailed Explanation", - "상세 해설: ": "Detailed Explanation: ", - "상세 해설을 불러오는데 실패했습니다.": "Failed to load detailed explanation.", - "상태:": "Status:", - "생성 중...": "Generating...", - "생성:": "Generation:", - "생성된 객관식 문제를 순서대로 풀이": "Solve the generated multiple-choice questions in order", - "생성된 문제 데이터:": "Generated Question Data:", - "생성된 문제는 학습 참고용이며, 사실관계가 100% 정확하지 않을 수 있습니다. 중요한 정보는 반드시 원본과 교차 확인하세요.": "Generated questions are for learning reference and may not be 100% factually accurate. Please cross-check important information with the original source.", - "생성일:": "Date Created:", - "서비스를 만드는데 큰 도움이 됩니다.": "is a great help in creating the service.", - "선택된 기록:": "Selected History:", - "선택한 답:": "Selected Answer:", - "선택한 답안": "Selected Answer", - "성과 확인: ": "Check Performance: ", - "소요 시간: ": "Time Elapsed: ", - "슬라이드의": "Of the slide", - "아니요. 현재는 텍스트 선택이 가능한 '텍스트 기반'의 PDF, PPT, PPTX 파일만 지원합니다. 스캔 본이나 사진 형태의 문서는 분석이 어렵습니다.": "No. Currently, only 'text-based' PDF, PPT, and PPTX files where text can be selected are supported. Scanned or photo-based documents are difficult to analyze.", - "아직 만든 퀴즈가 없습니다": "You haven't created any quizzes yet", - "안푼 문제:": "Unsolved Questions:", - "언어": "Language", - "언제든 이어서 풀거나 해설 다시 보기": "Continue solving or view explanations again anytime", - "업데이트된 히스토리:": "Updated History:", - "업로드 ": "Upload ", - "업로드 URL:": "Upload URL:", - "업로드된 문서를 AI가 분석하여 문제 생성": "AI analyzes the uploaded document to generate questions", - "에러 상세 정보:": "Error Details:", - "오답": "Incorrect", - "오답이 없습니다!": "No incorrect answers!", - "완료": "Complete", - "완료:": "Completed:", - "완료: ": "Completed: ", - "완료!": "Complete!", - "완료된 퀴즈 배열:": "Completed Quizzes Array:", - "완료된 퀴즈 수:": "Number of Completed Quizzes:", - "완료된 퀴즈만 해설을 볼 수 있습니다.": "Explanations are available only for completed quizzes.", - "완료율": "Completion Rate", - "완료율:": "Completion Rate:", - "완료일:": "Date Completed:", - "완료한 퀴즈": "Completed Quizzes", - "원하는 페이지를 지정하면 AI 퀴즈 생성시 더 좋은 퀴즈를 만들 수 있습니다.": "Specifying desired pages can help create better quizzes during AI quiz generation.", - "유효한 퀴즈 정보가 없습니다. 홈으로 이동합니다.": "No valid quiz information. Moving to home.", - "이 기록을 삭제하시겠습니까?": "Are you sure you want to delete this record?", - "이메일:": "Email:", - "이전": "Previous", - "입력 X": "No Input", - "자동 저장: ": "Auto Save: ", - "저장된 퀴즈 데이터 사용:": "Using Saved Quiz Data:", - "저장된 퀴즈 데이터가 없음. API로 데이터 가져오기": "No saved quiz data. Fetching data via API", - "저장할 퀴즈 데이터:": "Quiz Data to Save:", - "전체": "All", - "전체 기록 배열:": "Full History Array:", - "전체 기록 삭제 실패:": "Failed to Delete All History:", - "전체 데이터:": "All Data:", - "전체 또는 특정 페이지 지정": "Specify all or specific pages", - "전체 문제:": "Total Questions:", - "전체 삭제": "Delete All", - "전체 선택": "Select All", - "전체 퀴즈 수:": "Total Quizzes:", - "점": "points", - "점 (": "points (", - "점수": "Score", - "점수, 소요시간 등 결과 확인": "Check results like score, time taken, etc.", - "점수:": "Score:", - "정답": "Correct", - "정답 답안:": "Correct Answer:", - "제출 확인": "Confirm Submission", - "제출하기": "Submit", - "좌측 번호판으로 빠른 이동": "Quick navigation with the number pad on the left", - "지금까지 만들고 푼 퀴즈들을 확인해보세요": "Check the quizzes you've created and solved so far", - "지원 파일 형식: PPT, PPTX, PDF": "Supported File Formats: PPT, PPTX, PDF", - "지원 형식: ": "Supported Formats: ", - "지원하지 않는 파일 형식입니다": "Unsupported file format", - "초": "sec", - "총 기록 개수:": "Total History Count:", - "총 퀴즈 수": "Total Quizzes", - "총 퀴즈 수, 평균 점수 등 확인": "Check total quizzes, average score, etc.", - "최신 퀴즈 로딩 실패:": "Failed to Load Latest Quiz:", - "최종 퀴즈 배열:": "Final Quiz Array:", - "취소": "Cancel", - "퀴즈 결과 기록 업데이트 실패:": "Failed to Update Quiz Result History:", - "퀴즈 기록": "Quiz History", - "퀴즈 기록 불러오기 실패:": "Failed to Load Quiz History:", - "퀴즈 기록 저장 실패:": "Failed to Save Quiz History:", - "퀴즈 데이터 길이:": "Quiz Data Length:", - "퀴즈 데이터 응답:": "Quiz Data Response:", - "퀴즈 데이터 존재 여부:": "Quiz Data Exists:", - "퀴즈 데이터:": "Quiz Data:", - "퀴즈 레벨:": "Quiz Level:", - "퀴즈 만들기": "Create Quiz", - "퀴즈 배열 길이:": "Quiz Array Length:", - "퀴즈 보관 정책": "Quiz Retention Policy", - "퀴즈 생성 옵션": "Quiz Generation Options", - "퀴즈 풀기": "Solve Quiz", - "퀴즈를 만들어서 문제를 풀어보세요!": "Create a quiz and solve the problems!", - "텍스트가 선택되지 않는 PDF는 OCR 변환이 필요합니다!": "PDFs where text cannot be selected require OCR conversion!", - "특정 페이지를 지정하고 싶으신가요?": "Do you want to specify specific pages?", - "틀린 문제 중심 재학습 가능": "Re-learning focused on incorrect problems is possible", - "팁: ": "Tip: ", - "파일 page 제한: 선택했을 때 100page 이하": "File page limit: 100 pages or less when selected", - "파일 링크가 만료되었습니다.": "The file link has expired.", - "파일 선택하기": "Select File", - "파일 업로드 중...": "Uploading file...", - "파일 크기 제한:": "File Size Limit:", - "파일 크기에 따라 시간이 소요될 수 있습니다": "It may take time depending on the file size", - "파일명:": "File Name:", - "파일을 PDF로 변환하고 있어요": "Converting file to PDF", - "파일을 드래그하거나 버튼 클릭": "Drag file or click button", - "파일을 먼저 업로드해주세요.": "Please upload a file first.", - "파일을 여기에 드래그하세요": "Drag file here", - "파일이 존재하지 않습니다.": "File does not exist.", - "페이지": "Page", - "페이지 범위: ": "Page Range: ", - "평균 점수": "Average Score", - "평균 점수:": "Average Score:", - "해설": "Explanation", - "해설 데이터 로딩 실패:": "Failed to Load Explanation Data:", - "해설 데이터 응답:": "Explanation Data Response:", - "해설 데이터:": "Explanation Data:", - "해설 보기": "View Explanation", - "해설 페이지로 전달할 state 데이터:": "State data to pass to explanation page:", - "해설을 불러오는데 실패했습니다. 문제가 삭제되었을 수 있습니다.": "Failed to load explanation. The problem may have been deleted.", - "해설이 없습니다.": "No explanation available.", - "현재 생성중입니다 조금만 더 기다려주세요!": "Currently generating. Please wait a moment!", - "현재는 pdf 파일만 지원합니다.": "Currently, only PDF files are supported.", - "홈으로": "To Home", - "확인": "Confirm", - "빈칸 넣기": "Fill-in-the-blanks", - "문제 생성결과": "Question generation result" -} diff --git a/package-lock.json b/package-lock.json index b0e0d7c..b4a3fdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "react-ga4": "^2.1.0", "react-pdf": "^9.2.1", "react-router-dom": "^7.6.0", - "react-toastify": "^11.0.5" + "react-toastify": "^11.0.5", + "zustand": "^5.0.10" }, "devDependencies": { "@eslint/js": "^9.25.0", @@ -28,7 +29,8 @@ "eslint-plugin-react-refresh": "^0.4.19", "globals": "^16.0.0", "i18nexus-tools": "^1.5.0", - "vite": "^6.3.5" + "vite": "^6.3.5", + "vite-plugin-prerender": "^1.0.8" } }, "node_modules/@ampproject/remapping": { @@ -744,9 +746,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -762,19 +764,6 @@ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@eslint-community/regexpp": { "version": "4.12.1", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", @@ -786,13 +775,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -801,19 +790,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz", - "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", - "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -861,19 +853,22 @@ } }, "node_modules/@eslint/js": { - "version": "9.26.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.26.0.tgz", - "integrity": "sha512-I9XlJawFdSMvWjDt6wksMCrgns5ggLNfFwFvnShsleWruvXM514Qxk8V246efTw+eo9JABvVz+u3q2RiAowKxQ==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -881,13 +876,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", - "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.13.0", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -1025,36 +1020,360 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz", - "integrity": "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==", + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@prerenderer/prerenderer": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@prerenderer/prerenderer/-/prerenderer-0.7.2.tgz", + "integrity": "sha512-zWG3uFnrQWDJQoSzGB8bOnNhJCgIiylVYDFBP7Nw2LqngHOqwvpdBtGSjfajC8+fdR/iB2FqMqe27cfdmf/8TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "express": "^4.16.2", + "http-proxy-middleware": "^0.18.0", + "portfinder": "^1.0.13" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/@prerenderer/prerenderer/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "dev": true, "license": "MIT", "dependencies": { - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.3", - "eventsource": "^3.0.2", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" + "mime-types": "~2.1.34", + "negotiator": "0.6.3" }, "engines": { - "node": ">=18" + "node": ">= 0.6" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "node_modules/@prerenderer/prerenderer/node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "dev": true, "license": "MIT", - "optional": true, + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, "engines": { - "node": ">=14" + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/@prerenderer/prerenderer/node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@prerenderer/prerenderer/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prerenderer/prerenderer/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/@prerenderer/prerenderer/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prerenderer/prerenderer/node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@prerenderer/prerenderer/node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@prerenderer/prerenderer/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@prerenderer/prerenderer/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@prerenderer/prerenderer/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@prerenderer/prerenderer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@prerenderer/prerenderer/node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@prerenderer/prerenderer/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@prerenderer/prerenderer/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@prerenderer/prerenderer/node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prerenderer/prerenderer/node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@prerenderer/prerenderer/node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@prerenderer/prerenderer/node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@prerenderer/prerenderer/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@prerenderer/prerenderer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@prerenderer/renderer-puppeteer": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@prerenderer/renderer-puppeteer/-/renderer-puppeteer-0.2.0.tgz", + "integrity": "sha512-sC8WBcYcXbqm6premzCcUNDRROtAwBtBewUuzHyKcYDqU6InqjfpUQEXdIlhikN0gvqzlJy1+c7OJSfNYi4/tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "promise-limit": "^2.5.0", + "puppeteer": "^1.7.0" + }, + "engines": { + "node": ">=4.0.0" } }, "node_modules/@rollup/rollup-android-arm-eabi": { @@ -1436,24 +1755,10 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -1533,39 +1838,155 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" + "node_modules/arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } }, - "node_modules/axios": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", - "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "node_modules/arr-flatten": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", + "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", + "dev": true, "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true, "license": "MIT" }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { + "node_modules/array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "dev": true, + "license": "(MIT OR Apache-2.0)", + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, + "node_modules/axios": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", + "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", + "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cache-base": "^1.0.1", + "class-utils": "^0.3.5", + "component-emitter": "^1.2.1", + "define-property": "^1.0.0", + "isobject": "^3.0.1", + "mixin-deep": "^1.2.0", + "pascalcase": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/base/node_modules/define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { "type": "patreon", "url": "https://www.patreon.com/feross" }, @@ -1597,36 +2018,60 @@ "readable-stream": "^3.4.0" } }, - "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", "dev": true, "license": "MIT", "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.0", - "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" }, "engines": { - "node": ">=18" + "node": ">=0.10.0" } }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "node_modules/braces/node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/braces/node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, "node_modules/browserslist": { @@ -1687,12 +2132,29 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1703,6 +2165,27 @@ "node": ">= 0.8" } }, + "node_modules/cache-base": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", + "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "collection-visit": "^1.0.0", + "component-emitter": "^1.2.1", + "get-value": "^2.0.6", + "has-value": "^1.0.0", + "isobject": "^3.0.1", + "set-value": "^2.0.0", + "to-object-path": "^0.3.0", + "union-value": "^1.0.0", + "unset-value": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1742,6 +2225,17 @@ "node": ">=6" } }, + "node_modules/camel-case": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-3.0.0.tgz", + "integrity": "sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^2.2.0", + "upper-case": "^1.1.1" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001717", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001717.tgz", @@ -1802,6 +2296,62 @@ "license": "ISC", "optional": true }, + "node_modules/class-utils": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", + "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "define-property": "^0.2.5", + "isobject": "^3.0.0", + "static-extend": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/class-utils/node_modules/define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/class-utils/node_modules/is-descriptor": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/clean-css": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz", + "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==", + "dev": true, + "license": "MIT", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 4.0" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1811,6 +2361,20 @@ "node": ">=6" } }, + "node_modules/collection-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", + "integrity": "sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "map-visit": "^1.0.0", + "object-visit": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1850,6 +2414,16 @@ "node": ">=16" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1857,17 +2431,53 @@ "dev": true, "license": "MIT" }, - "node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", "dev": true, + "engines": [ + "node >= 0.8" + ], "license": "MIT", "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/concat-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" } }, "node_modules/content-type": { @@ -1897,29 +2507,22 @@ "node": ">= 0.6" } }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "node_modules/copy-descriptor": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", + "integrity": "sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw==", "dev": true, "license": "MIT", "engines": { - "node": ">=6.6.0" + "node": ">=0.10.0" } }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true, - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } + "license": "MIT" }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -1979,6 +2582,16 @@ } } }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -2012,6 +2625,20 @@ "dev": true, "license": "MIT" }, + "node_modules/define-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", + "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^1.0.2", + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2040,6 +2667,17 @@ "node": ">=6" } }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -2164,6 +2802,23 @@ "node": ">= 0.4" } }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/es6-promisify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "integrity": "sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es6-promise": "^4.0.3" + } + }, "node_modules/esbuild": { "version": "0.25.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", @@ -2236,34 +2891,32 @@ } }, "node_modules/eslint": { - "version": "9.26.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.26.0.tgz", - "integrity": "sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.1", - "@eslint/core": "^0.13.0", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.26.0", - "@eslint/plugin-kit": "^0.2.8", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", - "@modelcontextprotocol/sdk": "^1.8.0", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -2277,8 +2930,7 @@ "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "zod": "^3.24.2" + "optionator": "^0.9.3" }, "bin": { "eslint": "bin/eslint.js" @@ -2322,9 +2974,9 @@ } }, "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -2339,9 +2991,22 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2352,15 +3017,15 @@ } }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2369,12 +3034,25 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, - "license": "BSD-3-Clause", + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" }, @@ -2425,29 +3103,99 @@ "node": ">= 0.6" } }, - "node_modules/eventsource": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.6.tgz", - "integrity": "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==", + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/expand-brackets": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", + "integrity": "sha512-w/ozOKR9Obk3qoWeY/WDi6MFta9AoMR+zud60mdnbniMcBxRuFJyDt2LdX/14A1UABeqk+Uk+LDfUpvoGKppZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^2.3.3", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "posix-character-classes": "^0.1.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-brackets/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/expand-brackets/node_modules/define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", "dev": true, "license": "MIT", "dependencies": { - "eventsource-parser": "^3.0.1" + "is-descriptor": "^0.1.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=0.10.0" } }, - "node_modules/eventsource-parser": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz", - "integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==", + "node_modules/expand-brackets/node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-brackets/node_modules/is-descriptor": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/expand-brackets/node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=0.10.0" } }, + "node_modules/expand-brackets/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -2458,69 +3206,126 @@ "node": ">=6" } }, - "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.0", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extglob": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", + "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-unique": "^0.3.2", + "define-property": "^1.0.0", + "expand-brackets": "^2.1.4", + "extend-shallow": "^2.0.1", + "fragment-cache": "^0.2.1", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/express-rate-limit": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", - "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", + "node_modules/extglob/node_modules/define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==", "dev": true, "license": "MIT", + "dependencies": { + "is-descriptor": "^1.0.0" + }, "engines": { - "node": ">= 16" + "node": ">=0.10.0" + } + }, + "node_modules/extglob/node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extglob/node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extract-zip": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz", + "integrity": "sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "concat-stream": "^1.6.2", + "debug": "^2.6.9", + "mkdirp": "^0.5.4", + "yauzl": "^2.10.0" }, - "peerDependencies": { - "express": "^4.11 || 5 || ^5.0.0-beta.1" + "bin": { + "extract-zip": "cli.js" } }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "node_modules/extract-zip/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/extract-zip/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/extract-zip/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, "license": "MIT" }, "node_modules/fast-deep-equal": { @@ -2544,6 +3349,16 @@ "dev": true, "license": "MIT" }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fdir": { "version": "6.4.4", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", @@ -2572,22 +3387,43 @@ "node": ">=16.0.0" } }, - "node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "node_modules/fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" }, "engines": { - "node": ">= 0.8" + "node": ">=0.10.0" + } + }, + "node_modules/fill-range/node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fill-range/node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, "node_modules/find-up": { @@ -2648,6 +3484,16 @@ } } }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -2665,41 +3511,21 @@ } }, "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { "node": ">= 6" } }, - "node_modules/form-data/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/form-data/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2710,14 +3536,17 @@ "node": ">= 0.6" } }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "node_modules/fragment-cache": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", + "integrity": "sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==", "dev": true, "license": "MIT", + "dependencies": { + "map-cache": "^0.2.2" + }, "engines": { - "node": ">= 0.8" + "node": ">=0.10.0" } }, "node_modules/fs-constants": { @@ -2727,6 +3556,13 @@ "license": "MIT", "optional": true }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2828,6 +3664,16 @@ "node": ">= 0.4" } }, + "node_modules/get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -2836,9 +3682,9 @@ "optional": true }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -3023,6 +3869,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", + "integrity": "sha512-IBXk4GTsLYdQ7Rvt+GRBrFSVEkmuOUy4re0Xjd9kJSUQpnTrWR4/y9RpfexN9vkAPMFuQoeWKwqzPozRTlasGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-value": "^2.0.6", + "has-values": "^1.0.0", + "isobject": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-values": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", + "integrity": "sha512-ODYZC64uqzmtfGMEAX/FvZiRyWLpAC3vYnNunURUnkGVTS+mI0smVsWaPydRBsE3g+ok7h960jChO8mFcWlHaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^3.0.0", + "kind-of": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-values/node_modules/kind-of": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", + "integrity": "sha512-24XsCxmEbRwEDbz/qz3stgin8TTzZ1ESR56OMCN0ujYg+vRutNSiOj9bHH9u85DKgXguraugV5sFuvbD4FW/hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -3035,21 +3923,74 @@ "node": ">= 0.4" } }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-minifier": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/html-minifier/-/html-minifier-3.5.21.tgz", + "integrity": "sha512-LKUKwuJDhxNa3uf/LPR/KVjm/l3rBqtYeCOAekvG8F1vItxMUpueGd94i/asDDr8/1u7InxzFA5EeGjhhG5mMA==", "dev": true, "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "camel-case": "3.0.x", + "clean-css": "4.2.x", + "commander": "2.17.x", + "he": "1.2.x", + "param-case": "2.1.x", + "relateurl": "0.2.x", + "uglify-js": "3.4.x" + }, + "bin": { + "html-minifier": "cli.js" }, "engines": { - "node": ">= 0.8" + "node": ">=4" + } + }, + "node_modules/html-minifier/node_modules/commander": { + "version": "2.17.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz", + "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-0.18.0.tgz", + "integrity": "sha512-Fs25KVMPAIIcgjMZkVHJoKg9VcXcC1C8yb9JUgeDvVXY0S/zgVIhMb+qVswDIgtJe2DfckMSY2d6TuTEutlk6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy": "^1.16.2", + "is-glob": "^4.0.0", + "lodash": "^4.17.5", + "micromatch": "^3.1.9" + }, + "engines": { + "node": ">=4.0.0" } }, "node_modules/https-proxy-agent": { @@ -3129,19 +4070,6 @@ "node": ">=14.0.0" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -3200,6 +4128,18 @@ "node": ">=0.8.19" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -3224,6 +4164,66 @@ "node": ">= 0.10" } }, + "node_modules/is-accessor-descriptor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.1.tgz", + "integrity": "sha512-YBUanLI8Yoihw923YeFUS5fs0fF2f5TSFTNiYAAzhhDscDa3lEqYuz1pDOEP5KvX94I9ey3vsqjJcLVFVU+3QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-data-descriptor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.1.tgz", + "integrity": "sha512-bc4NlCDiCr28U4aEsQ3Qs2491gVq4V8G7MQyws968ImqjKuYtTJXrl7Vq7jsN7Ly/C3xj5KWFrY7sHNeDkAzXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-descriptor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.3.tgz", + "integrity": "sha512-JCNNGbwWZEVaSPtS45mdtrneRWJFp07LLmykxeFV5F6oBvNF8vHSfJuJgoT472pSfk+Mf8VnlrspaFBHWM8JAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3256,12 +4256,44 @@ "node": ">=0.10.0" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "node_modules/is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number/node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } }, "node_modules/is-stream": { "version": "2.0.1", @@ -3275,12 +4307,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -3303,9 +4362,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -3382,12 +4441,12 @@ } }, "node_modules/jws": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", - "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "license": "MIT", "dependencies": { - "jwa": "^2.0.0", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, @@ -3401,6 +4460,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3431,6 +4500,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3450,6 +4526,13 @@ "loose-envify": "cli.js" } }, + "node_modules/lower-case": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-1.1.4.tgz", + "integrity": "sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3478,36 +4561,36 @@ "url": "https://github.com/wojtekmaj/make-event-props?sponsor=1" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "node_modules/map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=0.10.0" } }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "node_modules/map-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", + "integrity": "sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==", "dev": true, "license": "MIT", + "dependencies": { + "object-visit": "^1.0.0" + }, "engines": { - "node": ">= 0.8" + "node": ">=0.10.0" } }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "dev": true, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.4" } }, "node_modules/merge-refs": { @@ -3527,24 +4610,70 @@ } } }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" } }, - "node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "node_modules/micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "license": "MIT", + "dependencies": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", "dev": true, "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", "dependencies": { - "mime-db": "^1.54.0" + "mime-db": "1.52.0" }, "engines": { "node": ">= 0.6" @@ -3580,8 +4709,8 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "devOptional": true, "license": "MIT", - "optional": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -3595,6 +4724,33 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mixin-deep": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", + "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -3627,6 +4783,29 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanomatch": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", + "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "fragment-cache": "^0.2.1", + "is-windows": "^1.0.2", + "kind-of": "^6.0.2", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -3641,14 +4820,14 @@ "dev": true, "license": "MIT" }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "node_modules/no-case": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-2.3.2.tgz", + "integrity": "sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.6" + "dependencies": { + "lower-case": "^1.1.1" } }, "node_modules/node-abi": { @@ -3711,12 +4890,57 @@ "dev": true, "license": "MIT" }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "node_modules/object-copy": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", + "integrity": "sha512-79LYn6VAb63zgtmAteVOWo9Vdj71ZVBy3Pbse+VqxDpEP83XuujMrGqHIwAXJ5I/aM0zU7dIyIAhifVTPrNItQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "copy-descriptor": "^0.1.0", + "define-property": "^0.2.5", + "kind-of": "^3.0.3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-copy/node_modules/define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-copy/node_modules/is-descriptor": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object-copy/node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", "dev": true, "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, "engines": { "node": ">=0.10.0" } @@ -3733,6 +4957,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-visit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", + "integrity": "sha512-GBaMwwAVK9qbQN3Scdo0OyvgPW7l3lnaVMj84uTOZlswkX0KpF6fyDBJhtTthf7pymztoN36/KEr1DyhF96zEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -3812,6 +5062,16 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/param-case": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-2.1.1.tgz", + "integrity": "sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^2.2.0" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3835,14 +5095,34 @@ "node": ">= 0.8" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "node_modules/pascalcase": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", + "integrity": "sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=0.10.0" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, "node_modules/path-key": { @@ -3876,16 +5156,6 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, - "node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - } - }, "node_modules/path2d": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/path2d/-/path2d-0.2.2.tgz", @@ -3909,6 +5179,13 @@ "path2d": "^0.2.1" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3928,14 +5205,28 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pkce-challenge": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", - "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "node_modules/portfinder": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", + "integrity": "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async": "^3.2.6", + "debug": "^4.3.6" + }, + "engines": { + "node": ">= 10.12" + } + }, + "node_modules/posix-character-classes": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", + "integrity": "sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==", "dev": true, "license": "MIT", "engines": { - "node": ">=16.20.0" + "node": ">=0.10.0" } }, "node_modules/postcss": { @@ -4004,6 +5295,30 @@ "node": ">= 0.8.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==", + "dev": true, + "license": "ISC" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -4045,10 +5360,69 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-1.20.0.tgz", + "integrity": "sha512-bt48RDBy2eIwZPrkgbcwHtb51mj2nKvHOPMaSH2IsWiv7lOG9k9zhaRzpDZafrk05ajMc3cu+lSQYYOfH2DkVQ==", + "deprecated": "< 24.15.0 is no longer supported", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0", + "extract-zip": "^1.6.6", + "https-proxy-agent": "^2.2.1", + "mime": "^2.0.3", + "progress": "^2.0.1", + "proxy-from-env": "^1.0.0", + "rimraf": "^2.6.1", + "ws": "^6.1.0" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/puppeteer/node_modules/agent-base": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz", + "integrity": "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es6-promisify": "^5.0.0" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/puppeteer/node_modules/https-proxy-agent": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz", + "integrity": "sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^4.3.0", + "debug": "^3.1.0" + }, + "engines": { + "node": ">= 4.5.0" + } + }, + "node_modules/puppeteer/node_modules/https-proxy-agent/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -4070,22 +5444,6 @@ "node": ">= 0.6" } }, - "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -4179,9 +5537,9 @@ } }, "node_modules/react-router": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.0.tgz", - "integrity": "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==", + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", + "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -4201,12 +5559,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.0.tgz", - "integrity": "sha512-DYgm6RDEuKdopSyGOWZGtDfSm7Aofb8CCzgkliTjtu/eDuB0gcsv6qdFhhi8HdtmA+KHkt5MfZ5K2PdzjugYsA==", + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", + "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", "license": "MIT", "dependencies": { - "react-router": "7.6.0" + "react-router": "7.13.0" }, "engines": { "node": ">=20.0.0" @@ -4217,12 +5575,16 @@ } }, "node_modules/react-router/node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/react-toastify": { @@ -4253,6 +5615,57 @@ "node": ">= 6" } }, + "node_modules/regex-not": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", + "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", + "dev": true, + "license": "MIT", + "dependencies": { + "extend-shallow": "^3.0.2", + "safe-regex": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/repeat-element": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz", + "integrity": "sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4263,6 +5676,60 @@ "node": ">=4" } }, + "node_modules/resolve-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", + "integrity": "sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==", + "deprecated": "https://github.com/lydell/resolve-url#deprecated", + "dev": true, + "license": "MIT" + }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rollup": { "version": "4.40.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.2.tgz", @@ -4303,23 +5770,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -4340,6 +5790,16 @@ ], "license": "MIT" }, + "node_modules/safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ret": "~0.1.10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -4363,50 +5823,50 @@ "semver": "bin/semver.js" } }, - "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/set-value": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.3.5", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" }, "engines": { - "node": ">= 18" + "node": ">=0.10.0" } }, - "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "node_modules/set-value/node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", "dev": true, "license": "MIT", "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" + "is-extendable": "^0.1.0" }, "engines": { - "node": ">= 18" + "node": ">=0.10.0" } }, - "node_modules/set-cookie-parser": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", - "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", - "license": "MIT" + "node_modules/set-value/node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } }, "node_modules/setprototypeof": { "version": "1.2.0", @@ -4567,6 +6027,167 @@ "simple-concat": "^1.0.0" } }, + "node_modules/snapdragon": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", + "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "base": "^0.11.1", + "debug": "^2.2.0", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "map-cache": "^0.2.2", + "source-map": "^0.5.6", + "source-map-resolve": "^0.5.0", + "use": "^3.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", + "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-property": "^1.0.0", + "isobject": "^3.0.0", + "snapdragon-util": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon-node/node_modules/define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon-util": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", + "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^3.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon-util/node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/snapdragon/node_modules/define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon/node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon/node_modules/is-descriptor": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/snapdragon/node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/snapdragon/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4577,14 +6198,81 @@ "node": ">=0.10.0" } }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "node_modules/source-map-resolve": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz", + "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==", + "deprecated": "See https://github.com/lydell/source-map-resolve#deprecated", "dev": true, "license": "MIT", + "dependencies": { + "atob": "^2.1.2", + "decode-uri-component": "^0.2.0", + "resolve-url": "^0.2.1", + "source-map-url": "^0.4.0", + "urix": "^0.1.0" + } + }, + "node_modules/source-map-url": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.1.tgz", + "integrity": "sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==", + "deprecated": "See https://github.com/lydell/source-map-url#deprecated", + "dev": true, + "license": "MIT" + }, + "node_modules/split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "extend-shallow": "^3.0.0" + }, "engines": { - "node": ">= 0.8" + "node": ">=0.10.0" + } + }, + "node_modules/static-extend": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", + "integrity": "sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-property": "^0.2.5", + "object-copy": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/static-extend/node_modules/define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/static-extend/node_modules/is-descriptor": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" } }, "node_modules/string_decoder": { @@ -4720,9 +6408,9 @@ } }, "node_modules/tar-fs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", - "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "license": "MIT", "optional": true, "dependencies": { @@ -4772,6 +6460,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/to-object-path": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", + "integrity": "sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-object-path/node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-regex": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", + "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "regex-not": "^1.0.2", + "safe-regex": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -4814,19 +6558,61 @@ "node": ">= 0.8.0" } }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/uglify-js": { + "version": "3.4.10", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.4.10.tgz", + "integrity": "sha512-Y2VsbPVs0FIshJztycsO2SfPk7/KAF/T72qzv9u5EpQ4kB2hQoHlhNQTsNyy6ul7lQtqJN/AoWeS23OzEiEFxw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "commander": "~2.19.0", + "source-map": "~0.6.1" + }, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/uglify-js/node_modules/commander": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.19.0.tgz", + "integrity": "sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/union-value": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", "dev": true, "license": "MIT", "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^2.0.1" }, "engines": { - "node": ">= 0.6" + "node": ">=0.10.0" + } + }, + "node_modules/union-value/node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, "node_modules/unpipe": { @@ -4839,6 +6625,58 @@ "node": ">= 0.8" } }, + "node_modules/unset-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", + "integrity": "sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-value": "^0.3.1", + "isobject": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unset-value/node_modules/has-value": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", + "integrity": "sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-value": "^2.0.3", + "has-values": "^0.1.4", + "isobject": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unset-value/node_modules/has-value/node_modules/isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "isarray": "1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unset-value/node_modules/has-values": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", + "integrity": "sha512-J8S0cEdWuQbqD9//tlZxiMuMNmxB8PlEwvYwuxsTmR1G5RXUePEX/SJn7aD0GMLieuZYSwNH0cQuJGwnYunXRQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -4870,6 +6708,13 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/upper-case": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-1.1.3.tgz", + "integrity": "sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==", + "dev": true, + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -4880,18 +6725,46 @@ "punycode": "^2.1.0" } }, + "node_modules/urix": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", + "integrity": "sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==", + "deprecated": "Please see https://github.com/lydell/urix#deprecated", + "dev": true, + "license": "MIT" + }, "node_modules/url-template": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", "license": "BSD" }, + "node_modules/use": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", + "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, "license": "MIT", - "optional": true + "engines": { + "node": ">= 0.4.0" + } }, "node_modules/uuid": { "version": "9.0.1", @@ -4917,9 +6790,9 @@ } }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", "dependencies": { @@ -4991,6 +6864,24 @@ } } }, + "node_modules/vite-plugin-prerender": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/vite-plugin-prerender/-/vite-plugin-prerender-1.0.8.tgz", + "integrity": "sha512-DSfzhm6LlIlN4QFHPCa3Vi6mCLeODpQnlBHar7LttLOEXykPspP8QZtknCCzYFRCf2176Wj+A0X/lwl/MXNnJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@prerenderer/prerenderer": "^0.7.2", + "@prerenderer/renderer-puppeteer": "^0.2.0", + "chalk": "^4.1.2", + "debug": "^4.3.3", + "html-minifier": "^3.5.16", + "mkdirp": "^1.0.4" + }, + "peerDependencies": { + "vite": ">=2.0.0" + } + }, "node_modules/warning": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", @@ -5136,6 +7027,16 @@ "devOptional": true, "license": "ISC" }, + "node_modules/ws": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", + "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-limiter": "~1.0.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -5143,6 +7044,17 @@ "dev": true, "license": "ISC" }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -5156,24 +7068,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/zod": { - "version": "3.24.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz", - "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", - "dev": true, + "node_modules/zustand": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz", + "integrity": "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==", "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.24.5", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", - "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", - "dev": true, - "license": "ISC", + "engines": { + "node": ">=12.20.0" + }, "peerDependencies": { - "zod": "^3.24.1" + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } } } } diff --git a/package.json b/package.json index aa9955a..1e5d719 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "remote": "vite --mode remote", "build": "vite build --mode prod", "lint": "eslint .", - "preview": "vite preview --mode prod" + "preview": "vite preview --port 5173" }, "dependencies": { "axios": "^1.9.0", @@ -19,7 +19,8 @@ "react-ga4": "^2.1.0", "react-pdf": "^9.2.1", "react-router-dom": "^7.6.0", - "react-toastify": "^11.0.5" + "react-toastify": "^11.0.5", + "zustand": "^5.0.10" }, "devDependencies": { "@eslint/js": "^9.25.0", @@ -31,12 +32,17 @@ "eslint-plugin-react-refresh": "^0.4.19", "globals": "^16.0.0", "i18nexus-tools": "^1.5.0", - "vite": "^6.3.5" + "vite": "^6.3.5", + "vite-plugin-prerender": "^1.0.8" }, "imports": { - "#shared/*": "./src/shared/*/index.js", - "#components/*": "./src/components/*/index.jsx", + "#app/*": "./src/app/*", "#pages/*": "./src/pages/*/index.jsx", - "#utils/*": "./src/utils/*.js" + "#features/*": "./src/features/*/index.js", + "#entities/*": "./src/entities/*/index.js", + "#widgets/*": "./src/widgets/*/index.jsx", + "#shared/*": "./src/shared/*/index.js", + "#shared/lib/*": "./src/shared/lib/*.js", + "#shared/i18n": "./src/shared/i18n/index.js" } } diff --git a/public/manifest.json b/public/manifest.json index 5df2392..c11da83 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,7 +1,7 @@ { - "name": "Q-Asker: PDF/PPT 파일로 무료 AI 퀴즈 생성", + "name": "Q-Asker: PDF, PPT, Word로 무료 AI 퀴즈 생성", "short_name": "Q-Asker", - "description": "PDF, PPT 파일만 업로드하면 AI가 자동으로 퀴즈를 생성해줍니다. 회원가입 없이 무료로 사용하고, 단계별 난이도로 학습 효과를 높여보세요.", + "description": "PDF, PPT, Word 파일을 업로드하면 AI가 퀴즈를 생성해줘요. 빈칸, OX, 객관식 문제로 시험에 완벽 대비할 수 있어요. 지금 회원가입 없이 무료로 시작하세요.", "theme_color": "#4F46E5", "background_color": "#ffffff", "display": "standalone", diff --git a/public/sitemap.xml b/public/sitemap.xml index cf88535..cdae522 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -1,17 +1,39 @@ - https://q-asker.com/ - 2025-07-16T05:39:43.156Z + https://www.q-asker.com/ + + + + 2026-01-31T00:00:00.000Z monthly 1.0 - https://q-asker.com/history - 2025-07-16T05:39:43.156Z + https://www.q-asker.com/ko + + + + 2026-01-31T00:00:00.000Z + monthly + 1.0 + + + https://www.q-asker.com/en + + + + 2026-01-31T00:00:00.000Z + monthly + 1.0 + + + https://www.q-asker.com/history + 2026-01-31T00:00:00.000Z monthly 0.8 diff --git a/src/index.css b/src/app/App.css similarity index 100% rename from src/index.css rename to src/app/App.css diff --git a/src/app/App.jsx b/src/app/App.jsx new file mode 100644 index 0000000..6d6d148 --- /dev/null +++ b/src/app/App.jsx @@ -0,0 +1,378 @@ +import React, { useEffect } from "react"; +import { + BrowserRouter, + Navigate, + Route, + Routes, + useLocation, + useNavigate, +} from "react-router-dom"; +import { ToastContainer } from "react-toastify"; +import "./App.css"; +import LoginSelect from "#pages/login-select"; +import LoginRedirect from "#pages/login-redirect"; +import MakeQuiz from "#pages/make-quiz"; +import PrivacyPolicy from "#pages/privacy-policy"; +import QuizExplanation from "#pages/quiz-explanation"; +import QuizHistory from "#pages/quiz-history"; +import QuizResult from "#pages/quiz-result"; +import SolveQuiz from "#pages/solve-quiz"; +import { I18nProvider, useLanguageSwitcher, useTranslation } from "i18nexus"; +import { translations } from "#shared/i18n"; +import PageViewTracker from "#app/ui/PageViewTracker"; +import { useInitGA } from "#app/model/useInitGA"; + +// Google Analytics 측정 ID (실제 GA4 측정 ID로 교체 필요) +const GA_MEASUREMENT_ID = import.meta.env.VITE_GA_MEASUREMENT_ID; + +const SUPPORTED_LANGUAGES = new Set(["ko", "en"]); + +const SEO_CONFIG = { + ko: { + title: "Q-Asker: PDF, PPT, Word로 무료 AI 퀴즈 생성", + description: + "PDF, PPT, Word 파일을 업로드하면 AI가 퀴즈를 생성해줘요. 빈칸, OX, 객관식 문제로 시험에 완벽 대비할 수 있어요. 지금 회원가입 없이 무료로 시작하세요.", + ogLocale: "ko_KR", + ogImageAlt: "Q-Asker AI 퀴즈 생성 서비스 소개 이미지", + twitterImageAlt: "Q-Asker AI 퀴즈 생성 서비스 소개 이미지", + jsonLd: { + howto: { + "@context": "https://schema.org", + "@type": "HowTo", + name: "학습 자료로 퀴즈를 생성하는 방법", + description: + "PDF, PPT, Word 파일을 업로드하면 AI가 퀴즈를 생성해줘요. 빈칸, OX, 객관식 문제로 시험에 완벽 대비할 수 있어요. 지금 회원가입 없이 무료로 시작하세요.", + inLanguage: "ko", + step: [ + { + "@type": "HowToStep", + name: "1단계: 학습 자료 파일 업로드", + text: "AI 퀴즈 생성을 위해 PDF, PPT, Word 등 학습 자료 파일을 업로드합니다. 원하는 페이지를 지정하면 AI 퀴즈 생성시 더 좋은 퀴즈를 만들 수 있습니다.", + url: "https://www.q-asker.com#how-to-use", + }, + { + "@type": "HowToStep", + name: "2단계: AI 퀴즈 옵션 설정", + text: "자동으로 생성할 문제 수량, 페이지 범위, 그리고 퀴즈 유형(빈칸, OX, 객관식)을 선택하여 맞춤형 AI 퀴즈 생성을 준비합니다.", + url: "https://www.q-asker.com#how-to-use", + }, + { + "@type": "HowToStep", + name: "3단계: AI 퀴즈 자동 생성", + text: "설정이 끝나면 AI가 문서 내용을 분석하여 퀴즈를 자동으로 생성합니다.", + url: "https://www.q-asker.com#how-to-use", + }, + { + "@type": "HowToStep", + name: "4단계: 생성된 퀴즈 풀기", + text: "AI가 만든 퀴즈를 풀어보며 학습 내용을 점검합니다. 나중에 다시 볼 문제는 체크 표시를 하여 효율적인 복습이 가능합니다.", + url: "https://www.q-asker.com#how-to-use", + }, + { + "@type": "HowToStep", + name: "5단계: 결과 확인 및 해설 학습", + text: "채점 결과와 함께 모든 문제에 대한 상세한 해설을 제공합니다. 참조한 페이지 미리보기를 통해 내용을 다시 확인하며 깊이 있는 학습을 할 수 있습니다.", + url: "https://www.q-asker.com#how-to-use", + }, + { + "@type": "HowToStep", + name: "6단계: 퀴즈 히스토리 관리", + text: "생성했던 모든 AI 퀴즈 기록이 자동으로 저장됩니다. 언제든지 다시 방문하여 복습하거나 이어서 문제를 풀 수 있습니다.", + url: "https://www.q-asker.com#how-to-use", + }, + ], + }, + website: { + "@context": "https://schema.org", + "@type": "WebSite", + name: "Q-Asker", + url: "https://www.q-asker.com", + inLanguage: "ko", + }, + organization: { + "@context": "https://schema.org", + "@type": "Organization", + name: "Q-Asker", + url: "https://www.q-asker.com", + logo: "https://www.q-asker.com/favicon-512x512.png", + }, + faq: { + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: [ + { + "@type": "Question", + name: "Q. Q-Asker는 정말 무료인가요?", + acceptedAnswer: { + "@type": "Answer", + text: "네, PDF, PPT, Word 기반 AI 퀴즈 생성은 현재 완전 무료입니다. 별도의 회원가입 없이 누구나 자유롭게 이용할 수 있습니다.", + }, + }, + { + "@type": "Question", + name: "Q. 업로드한 제 파일은 안전하게 관리되나요?", + acceptedAnswer: { + "@type": "Answer", + text: "네. 업로드된 파일은 퀴즈 생성을 위해서만 일시적으로 사용되며, 24시간 뒤에 삭제됩니다.", + }, + }, + { + "@type": "Question", + name: "Q. AI가 만든 퀴즈의 정확도는 어느 정도인가요?", + acceptedAnswer: { + "@type": "Answer", + text: "AI는 높은 정확도로 문서를 분석하지만, 100% 완벽하지 않을 수 있습니다. 생성된 문제는 학습 참고용이며, 중요한 정보는 반드시 원본과 교차 확인해주세요.", + }, + }, + { + "@type": "Question", + name: "Q. 이미지로 된 파일도 퀴즈로 만들 수 있나요?", + acceptedAnswer: { + "@type": "Answer", + text: "네. OCR을 지원하여 스캔 본이나 사진 형태의 문서도 분석할 수 있습니다.", + }, + }, + ], + }, + }, + }, + en: { + title: "Q-Asker: Free AI Quiz Generator for PDF, PPT, Word", + description: + "Upload PDF, PPT, and Word files to automatically generate AI quizzes. Prepare perfectly for exams with Fill-in-the-blank, True/False, and Multiple-choice questions. Start for free now without signing up.", + ogLocale: "en_US", + ogImageAlt: "Q-Asker AI quiz generator preview", + twitterImageAlt: "Q-Asker AI quiz generator preview", + jsonLd: { + howto: { + "@context": "https://schema.org", + "@type": "HowTo", + name: "How to generate quizzes from study materials", + description: + "Upload PDF, PPT, and Word files to automatically generate AI quizzes. Prepare perfectly for exams with Fill-in-the-blank, True/False, and Multiple-choice questions. Start for free now without signing up.", + inLanguage: "en", + step: [ + { + "@type": "HowToStep", + name: "Step 1: Upload your study file", + text: "Upload a PDF, PPT, or Word file to generate AI quizzes. Selecting specific pages helps create better questions.", + url: "https://www.q-asker.com#how-to-use", + }, + { + "@type": "HowToStep", + name: "Step 2: Configure quiz options", + text: + "Choose the number of questions, page range, and quiz types (fill-in-the-blank, true/false, multiple choice).", + url: "https://www.q-asker.com#how-to-use", + }, + { + "@type": "HowToStep", + name: "Step 3: Generate quizzes", + text: + "Once set, the AI analyzes the document and generates quizzes automatically.", + url: "https://www.q-asker.com#how-to-use", + }, + { + "@type": "HowToStep", + name: "Step 4: Solve the quizzes", + text: + "Practice with AI-generated quizzes and check your understanding. Mark questions to review later.", + url: "https://www.q-asker.com#how-to-use", + }, + { + "@type": "HowToStep", + name: "Step 5: Review results and explanations", + text: + "See scores and detailed explanations for every question. Preview the referenced pages to study in depth.", + url: "https://www.q-asker.com#how-to-use", + }, + { + "@type": "HowToStep", + name: "Step 6: Manage quiz history", + text: + "All generated quizzes are saved automatically. Revisit anytime to review or continue solving.", + url: "https://www.q-asker.com#how-to-use", + }, + ], + }, + website: { + "@context": "https://schema.org", + "@type": "WebSite", + name: "Q-Asker", + url: "https://www.q-asker.com", + inLanguage: "en", + }, + organization: { + "@context": "https://schema.org", + "@type": "Organization", + name: "Q-Asker", + url: "https://www.q-asker.com", + logo: "https://www.q-asker.com/favicon-512x512.png", + }, + faq: { + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: [ + { + "@type": "Question", + name: "Is Q-Asker really free?", + acceptedAnswer: { + "@type": "Answer", + text: + "Yes. AI quiz generation for PDF, PPT, and Word files is currently free with no signup required.", + }, + }, + { + "@type": "Question", + name: "Are my uploaded files secure?", + acceptedAnswer: { + "@type": "Answer", + text: + "Yes. Files are used only to generate quizzes and are deleted within 24 hours.", + }, + }, + { + "@type": "Question", + name: "How accurate are the AI-generated quizzes?", + acceptedAnswer: { + "@type": "Answer", + text: + "The AI is highly accurate, but not perfect. Use the questions as study aids and verify critical details with the original.", + }, + }, + { + "@type": "Question", + name: "Can I create quizzes from image files?", + acceptedAnswer: { + "@type": "Answer", + text: + "Yes. OCR is supported, so scans and photo-based documents can be analyzed too.", + }, + }, + ], + }, + }, + }, +}; + +const updateMetaContent = (selector, content) => { + if (!content) return; + const element = document.head.querySelector(selector); + if (!element) return; + element.setAttribute("content", content); +}; + +const updateLinkHref = (selector, href) => { + if (!href) return; + const element = document.head.querySelector(selector); + if (!element) return; + element.setAttribute("href", href); +}; + +const updateJsonLd = (id, data) => { + if (!data) return; + const element = document.head.querySelector(`#${id}`); + if (!element) return; + element.textContent = JSON.stringify(data); +}; + +const SeoMetaSync = () => { + const { currentLanguage } = useTranslation(); + const location = useLocation(); + + useEffect(() => { + const config = SEO_CONFIG[currentLanguage] ?? SEO_CONFIG.ko; + document.title = config.title; + document.documentElement.lang = currentLanguage; + + updateMetaContent('meta[name="description"]', config.description); + updateMetaContent('meta[property="og:title"]', config.title); + updateMetaContent('meta[property="og:description"]', config.description); + updateMetaContent('meta[property="og:locale"]', config.ogLocale); + updateMetaContent('meta[property="og:image:alt"]', config.ogImageAlt); + updateMetaContent('meta[name="twitter:title"]', config.title); + updateMetaContent('meta[name="twitter:description"]', config.description); + updateMetaContent('meta[name="twitter:image:alt"]', config.twitterImageAlt); + + const canonicalBase = "https://www.q-asker.com"; + const canonicalPath = location.pathname === "/" ? "/" : location.pathname; + const canonical = `${canonicalBase}${canonicalPath}`; + updateLinkHref('link[rel="canonical"]', canonical); + updateMetaContent('meta[property="og:url"]', canonical); + + updateJsonLd("ld-howto", config.jsonLd.howto); + updateJsonLd("ld-website", config.jsonLd.website); + updateJsonLd("ld-organization", config.jsonLd.organization); + updateJsonLd("ld-faq", config.jsonLd.faq); + }, [currentLanguage, location.pathname]); + + return null; +}; + +const LanguageRouteSync = () => { + const { changeLanguage } = useLanguageSwitcher(); + const { currentLanguage } = useTranslation(); + const location = useLocation(); + const navigate = useNavigate(); + + useEffect(() => { + if (location.pathname === "/") { + const browserLang = window.navigator?.language?.toLowerCase() ?? ""; + if (browserLang.startsWith("en")) { + navigate("/en", { replace: true }); + return; + } + } + const path = location.pathname; + const langFromPath = path === "/en" ? "en" : path === "/ko" ? "ko" : null; + if (!langFromPath || !SUPPORTED_LANGUAGES.has(langFromPath)) return; + if (currentLanguage === langFromPath) return; + changeLanguage(langFromPath); + }, [changeLanguage, currentLanguage, location.pathname, navigate]); + + return null; +}; + +const getInitialLanguage = () => { + if (typeof window === "undefined") return "ko"; + const path = window.location.pathname; + const langFromPath = path === "/en" ? "en" : path === "/ko" ? "ko" : null; + return langFromPath ?? "ko"; +}; + +const App = () => { + useInitGA(GA_MEASUREMENT_ID); + + return ( + + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } /> + } /> + + + + ); +}; + +export default App; diff --git a/src/App.css b/src/app/index.css similarity index 99% rename from src/App.css rename to src/app/index.css index ad15580..7687e96 100644 --- a/src/App.css +++ b/src/app/index.css @@ -38,4 +38,4 @@ body { input, textarea, select, button { font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -} \ No newline at end of file +} diff --git a/src/app/main.jsx b/src/app/main.jsx new file mode 100644 index 0000000..e417d28 --- /dev/null +++ b/src/app/main.jsx @@ -0,0 +1,11 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "#entities/auth"; +import "./index.css"; +import App from "./App.jsx"; + +createRoot(document.getElementById("root")).render( + + + +); diff --git a/src/app/model/pageTitles.js b/src/app/model/pageTitles.js new file mode 100644 index 0000000..3f11a00 --- /dev/null +++ b/src/app/model/pageTitles.js @@ -0,0 +1,20 @@ +const pathMap = { + "/": "퀴즈 생성", + "/login": "로그인", + "/login/redirect": "로그인 리다이렉트", + "/quiz": "퀴즈 풀기", + "/result": "퀴즈 결과", + "/explanation": "퀴즈 해설", + "/history": "퀴즈 기록", + "/privacy-policy": "개인정보 처리방침", +}; + +export const getPageTitle = (pathname) => { + for (const [key, title] of Object.entries(pathMap)) { + if (pathname.startsWith(key)) { + return title; + } + } + + return "알 수 없는 페이지"; +}; diff --git a/src/app/model/useInitGA.js b/src/app/model/useInitGA.js new file mode 100644 index 0000000..7849bf4 --- /dev/null +++ b/src/app/model/useInitGA.js @@ -0,0 +1,9 @@ +import { useEffect } from "react"; +import { initGA } from "#shared/lib/analytics"; + +export const useInitGA = (measurementId) => { + useEffect(() => { + if (!measurementId) return; + initGA(measurementId); + }, [measurementId]); +}; diff --git a/src/app/model/usePageViewTracker.js b/src/app/model/usePageViewTracker.js new file mode 100644 index 0000000..a0a2217 --- /dev/null +++ b/src/app/model/usePageViewTracker.js @@ -0,0 +1,19 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router-dom"; +import { logPageView } from "#shared/lib/analytics"; +import { getPageTitle } from "./pageTitles"; +import { writeLastEndpoint } from "#shared/lib/lastEndpointStorage"; + +export const usePageViewTracker = () => { + const location = useLocation(); + + useEffect(() => { + const pageTitle = getPageTitle(location.pathname); + const pathWithSearch = location.pathname + location.search; + logPageView(pathWithSearch, pageTitle); + + if (!location.pathname.startsWith("/login")) { + writeLastEndpoint(pathWithSearch); + } + }, [location]); +}; diff --git a/src/app/ui/PageViewTracker.jsx b/src/app/ui/PageViewTracker.jsx new file mode 100644 index 0000000..8b8c8b8 --- /dev/null +++ b/src/app/ui/PageViewTracker.jsx @@ -0,0 +1,9 @@ +import React from "react"; +import { usePageViewTracker } from "../model/usePageViewTracker"; + +const PageViewTracker = () => { + usePageViewTracker(); + return null; +}; + +export default PageViewTracker; diff --git a/src/components/header/index.css b/src/components/header/index.css deleted file mode 100644 index 2e628b0..0000000 --- a/src/components/header/index.css +++ /dev/null @@ -1,159 +0,0 @@ -.header { - background: white; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - position: relative; -} - -.header-inner { - width: 70%; - margin: 0 auto; - padding: 16px; - display: flex; - justify-content: space-between; - align-items: center; -} - -.logo-area { - display: flex; - align-items: center; -} - -.icon-button { - background: none; - border: none; - font-size: 24px; - cursor: pointer; - margin-right: 12px; -} - -.logo-link { - text-decoration: none; - display: flex; - align-items: center; - cursor: pointer; - user-select: none; -} - -.logo-icon { - width: 28px; - height: 28px; - margin-right: 8px; - margin-bottom: 5px; -} - -.logo-text { - font-size: 24px; - color: #6366f1; - font-weight: bold; -} - -.auth-buttons button { - margin-left: 12px; -} - -.text-button { - background: none; - border: none; - color: #6366f1; - cursor: pointer; -} - -.nav-link-area { - display: flex; - width: fit-content; - align-items: center; -} - -/* 사이드바 */ -.sidebar { - position: fixed; - top: 0; - left: 0; - width: 256px; - height: 100%; - background: white; - box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1); - transform: translateX(-100%); - transition: transform 0.3s ease-in-out; - z-index: 1000; -} - -.sidebar.open { - transform: translateX(0); -} - -.sidebar-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px; -} - -.sidebar nav { - width: 100%; -} - -.sidebar nav a { - display: block; - padding: 12px 16px; - color: #374151; - text-decoration: none; -} - -.sidebar nav a:hover { - background: #eef2ff; - color: #6366f1; -} - -/* 네비게이션 링크 버튼 스타일 */ -.nav-link { - display: block; - width: 100%; - padding: 12px 16px; - color: #374151; - text-decoration: none; - background: none; - border: none; - text-align: left; - cursor: pointer; - font-size: 16px; - transition: all 0.2s ease; - box-sizing: border-box; -} - -.nav-link:hover { - background: #eef2ff; - color: #6366f1; -} -.primary-button { - background: #6366f1; - color: white; - border: none; - padding: 8px 16px; - border-radius: 8px; - cursor: pointer; -} -.primary-button:hover { - background: #4f46e5; -} - -.language-selector { - display: flex; - align-items: center; - justify-content: space-between; -} - -.language-button { - background: none; - border: none; - cursor: pointer; - font-size: 16px; - color: #374151; -} - -@media (max-width: 768px) { - .header-inner { - width: auto; - padding: 10px; - } -} diff --git a/src/components/header/index.jsx b/src/components/header/index.jsx deleted file mode 100644 index 14450bb..0000000 --- a/src/components/header/index.jsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useTranslation, useLanguageSwitcher } from "i18nexus"; -import CustomToast from "#shared/toast"; -import React, { useEffect } from "react"; -import { Link } from "react-router-dom"; -import "./index.css"; - -const Header = ({ - isSidebarOpen, - toggleSidebar, - setIsSidebarOpen, - setShowHelp, -}) => { - const { changeLanguage } = useLanguageSwitcher(); - const { t } = useTranslation(); - useEffect(() => { - const handleClickOutside = (e) => { - const sidebar = document.getElementById("sidebar"); - const menuBtn = document.getElementById("menuButton"); - if ( - sidebar && - !sidebar.contains(e.target) && - menuBtn && - !menuBtn.contains(e.target) - ) { - setIsSidebarOpen(false); - } - }; - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [setIsSidebarOpen, setShowHelp]); - - const handleQuizManagement = () => { - setIsSidebarOpen(false); - }; - - const handleHelp = () => { - setIsSidebarOpen(false); // 메뉴창 닫기 - setShowHelp((prev) => { - if (!prev) { - // 도움말을 열 때만 스크롤 - setTimeout(() => { - const helpElement = document.getElementById("help-section"); - if (helpElement) { - helpElement.scrollIntoView({ behavior: "smooth", block: "start" }); - } - }, 100); - } - return !prev; - }); - }; - - return ( -
-
-
- - - Q-Asker - -
Q-Asker
- -
-
- - 📋 {t("퀴즈 기록")} - -
-
- -
- ); -}; - -export default Header; diff --git a/src/entities/auth/index.js b/src/entities/auth/index.js new file mode 100644 index 0000000..11374ba --- /dev/null +++ b/src/entities/auth/index.js @@ -0,0 +1,10 @@ +import { configureAuth } from "#shared/api"; +import { getAccessToken, useAuthStore } from "./store"; + +configureAuth({ + getAccessToken, + clearAuth: () => useAuthStore.getState().clearAuth(), +}); + +export { useAuthStore, getAccessToken } from "./store"; +export { authService } from "./service"; diff --git a/src/entities/auth/service.js b/src/entities/auth/service.js new file mode 100644 index 0000000..b115cba --- /dev/null +++ b/src/entities/auth/service.js @@ -0,0 +1,46 @@ +import axiosInstance from "#shared/api"; +import { useAuthStore } from "./store"; + +const decodeBase64Token = (token) => { + if (typeof token !== "string" || !token) return null; + const normalized = token.replace(/-/g, "+").replace(/_/g, "/"); + const padding = normalized.length % 4; + const padded = padding ? normalized.padEnd(normalized.length + (4 - padding), "=") : normalized; + try { + return atob(padded); + } catch (error) { + return token; + } +}; + +const applyAuthFromResponse = (response) => { + const rawAccessToken = response?.data?.accessToken; + const accessToken = decodeBase64Token(rawAccessToken); + if (accessToken) { + useAuthStore.getState().setAuth({ + accessToken, + user: response?.data?.user, + }); + } + return accessToken; +}; + +const refresh = async () => { + const response = await axiosInstance.post( + "/auth/refresh", + null, + { withCredentials: true, skipAuthRefresh: true, skipErrorToast: true } + ); + applyAuthFromResponse(response); + return response; +}; + +const logout = async () => { + try { + await axiosInstance.post("/auth/logout", null, { withCredentials: true }); + } finally { + useAuthStore.getState().clearAuth(); + } +}; + +export const authService = { refresh, logout }; diff --git a/src/entities/auth/store.js b/src/entities/auth/store.js new file mode 100644 index 0000000..beb1f5d --- /dev/null +++ b/src/entities/auth/store.js @@ -0,0 +1,27 @@ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +export const useAuthStore = create( + persist( + (set) => ({ + accessToken: null, + user: null, + setAuth: ({ accessToken, user }) => + set({ + accessToken: accessToken ?? null, + user: user ?? null, + }), + clearAuth: () => set({ accessToken: null, user: null }), + }), + { + name: "auth-storage", + storage: createJSONStorage(() => localStorage), + partialize: (state) => ({ + accessToken: state.accessToken, + user: state.user, + }), + } + ) +); + +export const getAccessToken = () => useAuthStore.getState().accessToken; diff --git a/src/features/auth/index.js b/src/features/auth/index.js new file mode 100644 index 0000000..19e7a59 --- /dev/null +++ b/src/features/auth/index.js @@ -0,0 +1,2 @@ +export { useLogin } from "./model/useLogin"; +export { useLoginRedirect } from "./model/useLoginRedirect"; diff --git a/src/features/auth/model/constants.js b/src/features/auth/model/constants.js new file mode 100644 index 0000000..a7363d3 --- /dev/null +++ b/src/features/auth/model/constants.js @@ -0,0 +1 @@ +export const LAST_ENDPOINT_STORAGE_KEY = "lastEndpoint"; diff --git a/src/features/auth/model/useLogin.js b/src/features/auth/model/useLogin.js new file mode 100644 index 0000000..ce0c8d6 --- /dev/null +++ b/src/features/auth/model/useLogin.js @@ -0,0 +1,62 @@ +import { useEffect, useState } from "react"; +import { authService, useAuthStore } from "#entities/auth"; +import CustomToast from "#shared/toast"; +import { + normalizeLastEndpoint, + readLastEndpoint, +} from "#shared/lib/lastEndpointStorage"; + +const buildLoginUrl = () => { + const baseUrl = import.meta.env.VITE_BASE_URL || ""; + const normalizedBaseUrl = baseUrl.endsWith("/") + ? baseUrl.slice(0, -1) + : baseUrl; + return `${normalizedBaseUrl}/auth/login`; +}; + +export const useLogin = ({ t, navigate }) => { + const accessToken = useAuthStore((state) => state.accessToken); + const [isChecking, setIsChecking] = useState(true); + + useEffect(() => { + let isMounted = true; + + const checkAuth = async () => { + try { + await authService.refresh(); + if (!isMounted) return; + const targetEndpoint = normalizeLastEndpoint(readLastEndpoint()); + navigate(targetEndpoint, { replace: true }); + } catch (error) { + if (isMounted) { + setIsChecking(false); + } + } + }; + + checkAuth(); + + return () => { + isMounted = false; + }; + }, [navigate]); + + useEffect(() => { + if (!accessToken) return; + const targetEndpoint = normalizeLastEndpoint(readLastEndpoint()); + navigate(targetEndpoint, { replace: true }); + }, [accessToken, navigate]); + + const handleLogin = () => { + try { + window.location.assign(buildLoginUrl()); + } catch (error) { + CustomToast.error(t("로그인에 실패했습니다. 다시 시도해주세요.")); + } + }; + + return { + state: { isChecking }, + actions: { handleLogin }, + }; +}; diff --git a/src/features/auth/model/useLoginRedirect.js b/src/features/auth/model/useLoginRedirect.js new file mode 100644 index 0000000..198ff9d --- /dev/null +++ b/src/features/auth/model/useLoginRedirect.js @@ -0,0 +1,61 @@ +import { useTranslation } from "i18nexus";import { useEffect } from "react"; +import { authService } from "#entities/auth"; +import CustomToast from "#shared/toast"; +import { + normalizeLastEndpoint, + readLastEndpoint } from +"#shared/lib/lastEndpointStorage"; + +let refreshPromise; + +const refreshOnce = async () => { + if (!refreshPromise) { + refreshPromise = authService.refresh().catch((error) => { + refreshPromise = null; + throw error; + }); + } + + return refreshPromise; +}; + +export const useLoginRedirect = ({ navigate }) => {const { t } = useTranslation(); + useEffect(() => { + let isMounted = true; + + const redirectAfterRefresh = async () => { + let refreshSucceeded = true; + try { + await refreshOnce(); + } catch (error) { + refreshSucceeded = false; + console.error(t("로그인 리다이렉트 실패:"), error); + } + + if (!refreshSucceeded) { + if (isMounted) { + CustomToast.error(t("로그인에 실패했습니다. 다시 로그인해주세요.")); + navigate("/login", { replace: true }); + } + return; + } + + const targetEndpoint = normalizeLastEndpoint(readLastEndpoint()); + + if (isMounted) { + navigate(targetEndpoint, { replace: true }); + } + }; + + redirectAfterRefresh(); + + return () => { + isMounted = false; + }; + }, [navigate]); + + return { + actions: {}, + state: {} + }; +}; \ No newline at end of file diff --git a/src/pages/MakeQuiz/util/fileUploader.js b/src/features/make-quiz/file-uploader.js similarity index 92% rename from src/pages/MakeQuiz/util/fileUploader.js rename to src/features/make-quiz/file-uploader.js index e12abeb..97ed2ac 100644 --- a/src/pages/MakeQuiz/util/fileUploader.js +++ b/src/features/make-quiz/file-uploader.js @@ -1,3 +1,4 @@ +import axios from "axios"; import axiosInstance from "#shared/api"; export async function uploadFileToServer(file) { @@ -11,11 +12,12 @@ export async function uploadFileToServer(file) { const encodedFileName = encodeURIComponent(file.name); - await axiosInstance.put(uploadUrl, file, { + await axios.put(uploadUrl, file, { headers: { "Content-Type": file.type, "x-amz-meta-original-filename": encodedFileName, }, + withCredentials: false, }); if (!isPdf) { @@ -40,4 +42,4 @@ async function pollForFile(url, timeout = 60000) { await new Promise((resolve) => setTimeout(resolve, 2000)); } throw new Error("변환 시간 초과"); -} \ No newline at end of file +} diff --git a/src/features/make-quiz/index.js b/src/features/make-quiz/index.js new file mode 100644 index 0000000..4c7c082 --- /dev/null +++ b/src/features/make-quiz/index.js @@ -0,0 +1,8 @@ +export { uploadFileToServer } from "./file-uploader"; +export { useMakeQuiz } from "./model/useMakeQuiz"; +export { + levelDescriptions, + MAX_FILE_SIZE, + MAX_SELECT_PAGES, + SUPPORTED_EXTENSIONS, +} from "./model/constants"; diff --git a/src/features/make-quiz/model/constants.js b/src/features/make-quiz/model/constants.js new file mode 100644 index 0000000..9495fbc --- /dev/null +++ b/src/features/make-quiz/model/constants.js @@ -0,0 +1,33 @@ +export const levelDescriptions = { + RECALL: { + title: "순수 암기나 단순 이해를 묻는 문제", + question: "예) 대한민국의 수도는 _______이다.", + options: ["서울", "부산", "대구", "광주"], + }, + SKILLS: { + title: "옳고 그름을 판별하는 문제", + question: "예) 지구는 태양 주위를 돈다.", + options: ["O", "X"], + }, + STRATEGIC: { + title: "추론, 문제 해결, 자료 해석을 요구하는 문제", + question: + "예) [전제] 물가가 오르면 화폐 가치는 떨어진다. 현재 물가가 급등했다.\n[질문] 이 경우 화폐 가치의 변화로 가장 적절한 것은?", + options: ["하락한다", "상승한다", "변함없다", "알 수 없다"], + }, +}; + +export const MAX_FILE_SIZE = 30 * 1024 * 1024; +export const MAX_SELECT_PAGES = 150; +export const SUPPORTED_EXTENSIONS = ["pdf","ppt", "pptx", "doc", "docx"]; + +export const levelMapping = { + BLANK: "RECALL", + OX: "SKILLS", + MULTIPLE: "STRATEGIC", +}; + +export const defaultType = "MULTIPLE"; + +export const pageCountToLoad = 50; +export const loadInterval = 2500; diff --git a/src/features/make-quiz/model/useMakeQuiz.js b/src/features/make-quiz/model/useMakeQuiz.js new file mode 100644 index 0000000..b550b1a --- /dev/null +++ b/src/features/make-quiz/model/useMakeQuiz.js @@ -0,0 +1,619 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { pdfjs } from "react-pdf"; +import { authService } from "#entities/auth"; +import CustomToast from "#shared/toast"; +import { trackMakeQuizEvents } from "#shared/lib/analytics"; +import Timer from "#shared/lib/timer"; +import { useClickOutside } from "#shared/lib/useClickOutside"; +import { getLatestQuizRecord, upsertQuizHistoryRecord } from "#shared/lib/quizHistoryStorage"; +import { useQuizGenerationStore } from "#features/quiz-generation"; +import { uploadFileToServer } from "../file-uploader"; +import { + defaultType, + levelMapping, + loadInterval, + MAX_FILE_SIZE, + MAX_SELECT_PAGES, + SUPPORTED_EXTENSIONS, + pageCountToLoad } from +"./constants"; + +pdfjs.GlobalWorkerOptions.workerSrc = new URL( + "pdfjs-dist/build/pdf.worker.min.mjs", + import.meta.url +).toString(); + +export const useMakeQuiz = ({ t, navigate }) => { + const [file, setFile] = useState(null); + const [uploadedUrl, setUploadedUrl] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const [questionType, setQuestionType] = useState(() => { + const savedType = localStorage.getItem("questionType"); + return savedType || defaultType; + }); + const [questionCount, setQuestionCount] = useState(10); + const [isProcessing, setIsProcessing] = useState(false); + const [version, setVersion] = useState(0); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [problemSetId, setProblemSetId] = useState(null); + const [quizLevel, setQuizLevel] = useState(() => { + const savedType = localStorage.getItem("questionType"); + return levelMapping[savedType || defaultType]; + }); + const [pageMode, setPageMode] = useState("CUSTOM"); + const [numPages, setNumPages] = useState(null); + const [selectedPages, setSelectedPages] = useState([]); + const [hoveredPage, setHoveredPage] = useState(null); + const [visiblePageCount, setVisiblePageCount] = useState(50); + const [pageRangeStart, setPageRangeStart] = useState(""); + const [pageRangeEnd, setPageRangeEnd] = useState(""); + const [isPreviewVisible, setIsPreviewVisible] = useState(true); + const pdfPreviewRef = useRef(null); + const [showWaitMessage, setShowWaitMessage] = useState(false); + const [uploadElapsedTime, setUploadElapsedTime] = useState(0); + const [generationElapsedTime, setGenerationElapsedTime] = useState(0); + const [fileExtension, setFileExtension] = useState(null); + const [showHelp, setShowHelp] = useState(false); + const uploadTimerRef = useRef(null); + const generationTimerRef = useRef(null); + const startGeneration = useQuizGenerationStore((state) => state.startGeneration); + + const pdfOptions = useMemo( + () => ({ + cMapUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/cmaps/`, + cMapPacked: true, + standardFontDataUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/standard_fonts/` + }), + [] + ); + + const getSelectablePageCount = useCallback( + (totalPages) => Math.min(totalPages, MAX_SELECT_PAGES), + [] + ); + + const applyAllPagesSelection = useCallback( + (totalPages) => { + const selectablePages = getSelectablePageCount(totalPages); + setNumPages(totalPages); + setSelectedPages( + Array.from({ length: selectablePages }, (_, i) => i + 1) + ); + }, + [getSelectablePageCount] + ); + + useEffect(() => { + setQuizLevel(levelMapping[questionType]); + localStorage.setItem("questionType", questionType); + }, [questionType]); + + const toggleSidebar = () => setIsSidebarOpen((prev) => !prev); + + useClickOutside({ + containerId: "sidebar", + triggerId: "menuButton", + onOutsideClick: () => setIsSidebarOpen(false) + }); + + const handleDragOver = (e) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragEnter = (e) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (e) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleDrop = (e) => { + e.preventDefault(); + setIsDragging(false); + if (e.dataTransfer.files.length > 0) { + selectFile(e.dataTransfer.files[0], "drag_drop"); + } + }; + + const handleFileInput = (e) => { + if (e.target.files.length > 0) selectFile(e.target.files[0], "click"); + }; + + const selectFile = async (nextFile, method = "click") => { + const ext = nextFile.name.split(".").pop().toLowerCase(); + + if (!SUPPORTED_EXTENSIONS.includes(ext)) { + CustomToast.error(t("지원하지 않는 파일 형식입니다")); + return; + } + + if (nextFile.size > MAX_FILE_SIZE) { + CustomToast.error( + `파일 크기는 ${MAX_FILE_SIZE / 1024 / 1024}MB를 초과할 수 없습니다.` + ); + return; + } + + if (method === "drag_drop") { + trackMakeQuizEvents.dragDropFileUpload(nextFile.name, nextFile.size, ext); + } else { + trackMakeQuizEvents.startFileUpload(nextFile.name, nextFile.size, ext); + } + + uploadTimerRef.current = new Timer((elapsed) => { + setUploadElapsedTime(elapsed); + }); + uploadTimerRef.current.start(); + + setFileExtension(ext); + setIsProcessing(true); + try { + const uploaded = await uploadFileToServer(nextFile); + setUploadedUrl(uploaded); + setFile(nextFile); + + const uploadTime = uploadTimerRef.current.stop(); + trackMakeQuizEvents.completeFileUpload(nextFile.name, uploadTime); + } catch (error) { + if (uploadTimerRef.current) { + uploadTimerRef.current.stop(); + } + + const message = + error?.message === t("변환 시간 초과") ? + t("파일 변환이 지연되고 있어요. 잠시 후 다시 시도해주세요.") : + error?.response?.data?.message || + error?.message || + t("파일 업로드 중 오류가 발생했습니다. 다시 시도해주세요."); + + CustomToast.error(message); + console.error(t("파일 업로드 실패:"), error); + return; + } finally { + setFileExtension(null); + setIsProcessing(false); + setUploadElapsedTime(0); + } + }; + + const generateQuestions = async () => { + if (!uploadedUrl) { + CustomToast.error(t("파일을 먼저 업로드해주세요.")); + return; + } + if (!selectedPages.length) { + CustomToast.error(t("페이지를 선택해주세요.")); + return; + } + + const apiQuizType = questionType; + + setIsProcessing(true); + try { + try { + await authService.refresh(); + } catch (refreshError) { + + // ignore refresh error and continue with generation + } + generationTimerRef.current = new Timer((elapsed) => { + setGenerationElapsedTime(elapsed); + }); + generationTimerRef.current.start(); + + startGeneration({ + requestData: { + uploadedUrl: uploadedUrl, + quizCount: questionCount, + quizType: apiQuizType, + difficultyType: quizLevel, + pageNumbers: selectedPages + }, + onFirstChunk: ({ problemSetId: nextProblemSetId }) => { + setProblemSetId(nextProblemSetId); + setVersion((prev) => prev + 1); + saveQuizToHistory(nextProblemSetId, file.name); + }, + onComplete: (nextProblemSetId) => { + if (generationTimerRef.current) { + const generationTime = generationTimerRef.current.stop(); + trackMakeQuizEvents.completeQuizGeneration( + nextProblemSetId, + generationTime + ); + } + setIsProcessing(false); + }, + onError: (error) => { + if (generationTimerRef.current) { + generationTimerRef.current.stop(); + } + const message = + error?.message || t("문제 생성 중 오류가 발생했습니다."); + CustomToast.error(message); + setIsProcessing(false); + setGenerationElapsedTime(0); + } + }); + } catch (error) { + if (generationTimerRef.current) { + generationTimerRef.current.stop(); + } + setIsProcessing(false); + setGenerationElapsedTime(0); + } + }; + + const saveQuizToHistory = (nextProblemSetId, fileName) => { + try { + const newQuizRecord = { + problemSetId: nextProblemSetId, + fileName, + fileSize: file.size, + questionCount, + quizLevel, + createdAt: new Date().toISOString(), + uploadedUrl, + status: "created", + score: null, + correctCount: null, + totalTime: null + }; + + upsertQuizHistoryRecord(newQuizRecord, { max: 20 }); + } catch (error) { + console.error(t("퀴즈 기록 저장 실패:"), error); + } + }; + + const loadLatestQuiz = () => { + try { + const latest = getLatestQuizRecord(); + if (!latest || uploadedUrl) return; + + setProblemSetId(latest.problemSetId); + const virtualFile = { + name: latest.fileName, + size: latest.fileSize + }; + setFile(virtualFile); + setUploadedUrl(latest.uploadedUrl); + } catch (error) { + console.error(t("최신 퀴즈 로딩 실패:"), error); + } + }; + + useEffect(() => { + let timer; + if (isProcessing && uploadedUrl && !problemSetId) { + timer = setTimeout(() => { + setShowWaitMessage(true); + }, 5000); + } else { + setShowWaitMessage(false); + } + + return () => { + if (timer) clearTimeout(timer); + }; + }, [isProcessing, uploadedUrl, problemSetId]); + + useEffect(() => { + loadLatestQuiz(); + trackMakeQuizEvents.viewMakeQuiz(); + }, []); + + useEffect(() => { + if (!uploadedUrl) return; + + let cancelled = false; + const loadPdfMetadata = async () => { + try { + const loadingTask = pdfjs.getDocument(uploadedUrl); + const pdf = await loadingTask.promise; + if (cancelled) { + if (loadingTask?.destroy) { + loadingTask.destroy(); + } + return; + } + applyAllPagesSelection(pdf.numPages); + if (pdf?.destroy) { + pdf.destroy(); + } + } catch (error) { + console.error(t("PDF 메타데이터 로드 실패:"), error); + } + }; + + loadPdfMetadata(); + + return () => { + cancelled = true; + }; + }, [uploadedUrl, applyAllPagesSelection]); + + useEffect(() => { + if (!numPages) return; + setPageRangeStart("1"); + setPageRangeEnd(String(Math.min(numPages, MAX_SELECT_PAGES))); + }, [numPages]); + + useEffect(() => { + if (!numPages || numPages <= pageCountToLoad) return; + + setVisiblePageCount(pageCountToLoad); + + const interval = setInterval(() => { + setVisiblePageCount((prev) => { + const nextCount = prev + pageCountToLoad; + if (nextCount >= numPages) { + clearInterval(interval); + return numPages; + } + return nextCount; + }); + }, loadInterval); + + return () => clearInterval(interval); + }, [numPages]); + + const resetAllStates = () => { + if (uploadTimerRef.current) { + uploadTimerRef.current.reset(); + uploadTimerRef.current = null; + } + + setFile(null); + setUploadedUrl(null); + setIsDragging(false); + setQuestionType(defaultType); + setQuestionCount(15); + setIsProcessing(false); + setVersion(0); + setIsSidebarOpen(false); + setProblemSetId(null); + setQuizLevel(levelMapping[defaultType]); + setPageMode("CUSTOM"); + setNumPages(null); + setSelectedPages([]); + setHoveredPage(null); + setVisiblePageCount(100); + setPageRangeStart(""); + setPageRangeEnd(""); + setIsPreviewVisible(true); + setShowWaitMessage(false); + setUploadElapsedTime(0); + setGenerationElapsedTime(0); + setFileExtension(null); + }; + + const handleRemoveFile = () => { + if (file) { + trackMakeQuizEvents.deleteFile(file.name); + } + resetAllStates(); + }; + + const handleReCreate = () => { + setProblemSetId(null); + setPageMode("ALL"); + setNumPages(null); + setSelectedPages([]); + setHoveredPage(null); + setVisiblePageCount(100); + setPageRangeStart("1"); + setPageRangeEnd(String(Math.min(numPages ?? 1, MAX_SELECT_PAGES))); + setIsPreviewVisible(true); + setShowWaitMessage(false); + setUploadElapsedTime(0); + setGenerationElapsedTime(0); + }; + + const handleNavigateToQuiz = () => { + trackMakeQuizEvents.navigateToQuiz(problemSetId); + navigate(`/quiz/${problemSetId}`, { + state: { uploadedUrl } + }); + }; + + const onDocumentLoadSuccess = ({ numPages: nextNumPages }) => { + applyAllPagesSelection(nextNumPages); + }; + + const handlePageSelection = (pageNumber) => { + setSelectedPages((prevSelectedPages) => { + if (prevSelectedPages.includes(pageNumber)) { + return prevSelectedPages.filter((p) => p !== pageNumber); + } + if (prevSelectedPages.length >= MAX_SELECT_PAGES) { + return prevSelectedPages; + } + return [...prevSelectedPages, pageNumber].sort((a, b) => a - b); + }); + }; + + const handleSelectAllPages = () => { + if (!numPages) { + return; + } + const selectablePages = getSelectablePageCount(numPages); + if (selectedPages.length === selectablePages) { + setSelectedPages([]); + } else { + setSelectedPages( + Array.from({ length: selectablePages }, (_, i) => i + 1) + ); + } + }; + + const handleClearAllPages = () => { + setSelectedPages([]); + }; + + const handleApplyPageRange = () => { + if (pageMode !== "CUSTOM" || !numPages) return; + + const startValue = pageRangeStart === "" ? "1" : pageRangeStart; + const endValue = + pageRangeEnd === "" ? String(numPages) : pageRangeEnd; + const parsedStart = parseInt(startValue, 10); + const parsedEnd = parseInt(endValue, 10); + + if (!Number.isFinite(parsedStart) || !Number.isFinite(parsedEnd)) { + CustomToast.error(t("페이지 범위를 올바르게 입력해주세요.")); + return; + } + + let start = Math.max(1, Math.min(parsedStart, numPages)); + let end = Math.max(1, Math.min(parsedEnd, numPages)); + + if (start > end) { + [start, end] = [end, start]; + } + + if (end - start + 1 > MAX_SELECT_PAGES) { + end = start + MAX_SELECT_PAGES - 1; + if (end > numPages) { + end = numPages; + start = Math.max(1, end - MAX_SELECT_PAGES + 1); + } + CustomToast.error( + t(`최대 ${MAX_SELECT_PAGES} 페이지 선택할 수 있어요`) + ); + } + + setPageRangeStart(String(start)); + setPageRangeEnd(String(end)); + setSelectedPages( + Array.from({ length: end - start + 1 }, (_, i) => start + i) + ); + }; + + const handlePageMouseEnter = (e, pageNumber) => { + if (window.innerWidth <= 768) return; + + if (!pdfPreviewRef.current) return; + + const containerRect = pdfPreviewRef.current.getBoundingClientRect(); + const itemRect = e.currentTarget.getBoundingClientRect(); + const itemWidth = itemRect.width; + const midpoint = containerRect.left + containerRect.width / 2; + + const PREVIEW_WIDTH = 660; + const GAP = 10; + + let top = itemRect.top - containerRect.top - 100; + if (top < 0) { + top = 0; + } + + const style = { + top: `${top}px`, + width: `${PREVIEW_WIDTH}px` + }; + + if (itemRect.left < midpoint) { + style.left = `${itemRect.left - containerRect.left + itemWidth + GAP}px`; + } else { + style.left = `${ + itemRect.left - containerRect.left - PREVIEW_WIDTH - GAP}px`; + + } + + setHoveredPage({ pageNumber, style }); + }; + + const handlePageMouseLeave = () => { + setHoveredPage(null); + }; + + const handleQuestionTypeChange = (nextType, label) => { + if (questionType !== nextType) { + trackMakeQuizEvents.changeQuizOption("question_type", label); + setQuestionType(nextType); + setQuizLevel(levelMapping[nextType]); + } + }; + + const handleQuestionCountChange = (nextCount) => { + if (questionCount !== nextCount) { + trackMakeQuizEvents.changeQuizOption("question_count", nextCount); + setQuestionCount(nextCount); + } + }; + + const handlePageModeChange = (mode) => { + setPageMode(mode); + if (mode === "ALL") { + const selectablePages = getSelectablePageCount(numPages ?? 0); + setSelectedPages( + Array.from({ length: selectablePages }, (_, i) => i + 1) + ); + } else { + setSelectedPages([]); + } + trackMakeQuizEvents.changeQuizOption("page_mode", mode); + }; + + return { + state: { + file, + uploadedUrl, + isDragging, + questionType, + questionCount, + isProcessing, + version, + isSidebarOpen, + problemSetId, + quizLevel, + pageMode, + numPages, + selectedPages, + hoveredPage, + visiblePageCount, + pageRangeStart, + pageRangeEnd, + isPreviewVisible, + pdfPreviewRef, + showWaitMessage, + uploadElapsedTime, + generationElapsedTime, + fileExtension, + showHelp, + pdfOptions + }, + actions: { + toggleSidebar, + setIsSidebarOpen, + setShowHelp, + handleDragOver, + handleDragEnter, + handleDragLeave, + handleDrop, + handleFileInput, + handleRemoveFile, + handleReCreate, + handleNavigateToQuiz, + onDocumentLoadSuccess, + handlePageSelection, + handleSelectAllPages, + handleClearAllPages, + handleApplyPageRange, + setPageRangeStart, + setPageRangeEnd, + setIsPreviewVisible, + handlePageMouseEnter, + handlePageMouseLeave, + generateQuestions, + handleQuestionTypeChange, + handleQuestionCountChange, + handlePageModeChange + } + }; +}; \ No newline at end of file diff --git a/src/features/quiz-explanation/index.js b/src/features/quiz-explanation/index.js new file mode 100644 index 0000000..29ba41e --- /dev/null +++ b/src/features/quiz-explanation/index.js @@ -0,0 +1 @@ +export { useQuizExplanation } from "./model/useQuizExplanation"; diff --git a/src/features/quiz-explanation/model/useQuizExplanation.jsx b/src/features/quiz-explanation/model/useQuizExplanation.jsx new file mode 100644 index 0000000..6c45578 --- /dev/null +++ b/src/features/quiz-explanation/model/useQuizExplanation.jsx @@ -0,0 +1,265 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { pdfjs } from "react-pdf"; +import axiosInstance from "#shared/api"; +import CustomToast from "#shared/toast"; +import { trackQuizEvents } from "#shared/lib/analytics"; + +pdfjs.GlobalWorkerOptions.workerSrc = new URL( + "pdfjs-dist/build/pdf.worker.min.mjs", + import.meta.url +).toString(); + +export const useQuizExplanation = ({ + t, + navigate, + problemSetId, + initialQuizzes, + rawExplanation, + uploadedUrl, +}) => { + const [showPdf, setShowPdf] = useState(false); + const [pdfWidth, setPdfWidth] = useState(600); + const pdfContainerRef = useRef(null); + const [currentPdfPage, setCurrentPdfPage] = useState(0); + const [showWrongOnly, setShowWrongOnly] = useState(false); + const [specificExplanation, setSpecificExplanation] = useState(""); + const [isSpecificExplanationLoading, setIsSpecificExplanationLoading] = + useState(false); + const [currentQuestion, setCurrentQuestion] = useState(1); + const [isLoading, setIsLoading] = useState(true); + + const pdfOptions = useMemo( + () => ({ + cMapUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/cmaps/`, + cMapPacked: true, + standardFontDataUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/standard_fonts/`, + }), + [] + ); + + const totalQuestions = initialQuizzes.length; + const allExplanation = Array.isArray(rawExplanation.results) + ? rawExplanation.results + : []; + + const filteredQuizzes = useMemo(() => { + if (!showWrongOnly) return initialQuizzes; + + return initialQuizzes.filter((q) => { + if (q.userAnswer === undefined || q.userAnswer === null) return false; + + const correctOption = q.selections.find((opt) => opt.correct === true); + if (!correctOption) return false; + + return Number(q.userAnswer) !== Number(correctOption.id); + }); + }, [initialQuizzes, showWrongOnly]); + + const filteredTotalQuestions = filteredQuizzes.length; + + const currentQuiz = useMemo(() => { + return showWrongOnly + ? filteredQuizzes[currentQuestion - 1] || { selections: [], userAnswer: 0 } + : initialQuizzes[currentQuestion - 1] || { selections: [], userAnswer: 0 }; + }, [showWrongOnly, filteredQuizzes, initialQuizzes, currentQuestion]); + + const thisExplanationObj = useMemo(() => { + return allExplanation.find((e) => e.number === currentQuiz.number) || {}; + }, [allExplanation, currentQuiz.number]); + + const thisExplanationText = + thisExplanationObj.explanation || t("해설이 없습니다."); + + const handleExit = (targetPath = "/") => { + navigate(targetPath); + }; + + useEffect(() => { + if (!problemSetId || initialQuizzes.length === 0) { + CustomToast.error(t("유효한 퀴즈 정보가 없습니다. 홈으로 이동합니다.")); + navigate("/"); + } else { + setIsLoading(false); + trackQuizEvents.viewExplanation(problemSetId, currentQuestion); + } + }, [problemSetId, initialQuizzes.length, navigate, currentQuestion, t]); + + useEffect(() => { + const calculatePdfWidth = () => { + if (pdfContainerRef.current) { + const containerWidth = pdfContainerRef.current.offsetWidth; + const isMobile = window.innerWidth <= 768; + const padding = isMobile ? 20 : 40; + const maxWidth = isMobile + ? containerWidth - padding + : Math.min(containerWidth - padding, 1200); + setPdfWidth(maxWidth); + } + }; + + calculatePdfWidth(); + window.addEventListener("resize", calculatePdfWidth); + window.addEventListener("orientationchange", calculatePdfWidth); + + return () => { + window.removeEventListener("resize", calculatePdfWidth); + window.removeEventListener("orientationchange", calculatePdfWidth); + }; + }, [showPdf]); + + useEffect(() => { + setCurrentPdfPage(0); + setSpecificExplanation(""); + }, [currentQuestion]); + + useEffect(() => { + if (showWrongOnly) { + if (filteredTotalQuestions === 0) { + setShowWrongOnly(false); + CustomToast.error(t("오답이 없습니다!")); + return; + } + + if (currentQuestion > filteredTotalQuestions) { + setCurrentQuestion(1); + } + } + }, [showWrongOnly, filteredTotalQuestions, currentQuestion, t]); + + const handlePrev = () => { + if (currentQuestion > 1) { + const prevQuestion = currentQuestion - 1; + trackQuizEvents.navigateQuestion( + problemSetId, + currentQuestion, + prevQuestion + ); + setCurrentQuestion(prevQuestion); + } + }; + + const handleNext = () => { + const maxQuestions = showWrongOnly + ? filteredTotalQuestions + : totalQuestions; + if (currentQuestion < maxQuestions) { + const nextQuestion = currentQuestion + 1; + trackQuizEvents.navigateQuestion( + problemSetId, + currentQuestion, + nextQuestion + ); + setCurrentQuestion(nextQuestion); + } + }; + + const handleFetchSpecificExplanation = async () => { + setIsSpecificExplanationLoading(true); + try { + const response = await axiosInstance.get( + `/specific-explanation/${problemSetId}?number=${currentQuiz.number}` + ); + setSpecificExplanation(response.data.specificExplanation); + } catch (error) { + console.error(t("상세 해설을 불러오는데 실패했습니다."), error); + CustomToast.error(t("상세 해설을 불러오는데 실패했습니다.")); + } finally { + setIsSpecificExplanationLoading(false); + } + }; + + const handleQuestionClick = (questionNumber) => { + if (questionNumber !== currentQuestion) { + trackQuizEvents.navigateQuestion( + problemSetId, + currentQuestion, + questionNumber + ); + setCurrentQuestion(questionNumber); + } + }; + + const handlePdfToggle = () => { + const newShowPdf = !showPdf; + setShowPdf(newShowPdf); + trackQuizEvents.togglePdfSlide(problemSetId, newShowPdf); + }; + + const handleWrongOnlyToggle = () => { + const newShowWrongOnly = !showWrongOnly; + setShowWrongOnly(newShowWrongOnly); + setCurrentQuestion(1); + }; + + const handlePrevPdfPage = () => { + if (currentPdfPage > 0) { + setCurrentPdfPage(currentPdfPage - 1); + } + }; + + const handleNextPdfPage = () => { + const currentPages = thisExplanationObj?.referencedPages || []; + if (currentPdfPage < currentPages.length - 1) { + setCurrentPdfPage(currentPdfPage + 1); + } + }; + + const renderTextWithLinks = (text) => { + if (!text) return text; + + const urlRegex = /(https?:\/\/[^\s)]+)/g; + const parts = text.split(urlRegex); + + return parts.map((part, index) => { + if (urlRegex.test(part)) { + return ( + + {part} + + ); + } + return part; + }); + }; + + return { + state: { + uploadedUrl, + showPdf, + pdfWidth, + pdfContainerRef, + currentPdfPage, + showWrongOnly, + specificExplanation, + isSpecificExplanationLoading, + currentQuestion, + totalQuestions, + filteredQuizzes, + filteredTotalQuestions, + isLoading, + currentQuiz, + thisExplanationText, + thisExplanationObj, + pdfOptions, + }, + actions: { + handleExit, + handlePrev, + handleNext, + handleFetchSpecificExplanation, + handleQuestionClick, + handlePdfToggle, + handleWrongOnlyToggle, + handlePrevPdfPage, + handleNextPdfPage, + setCurrentPdfPage, + renderTextWithLinks, + }, + }; +}; diff --git a/src/features/quiz-generation/index.js b/src/features/quiz-generation/index.js new file mode 100644 index 0000000..92f0ba9 --- /dev/null +++ b/src/features/quiz-generation/index.js @@ -0,0 +1 @@ +export { useQuizGenerationStore } from "./model/useQuizGenerationStore"; diff --git a/src/features/quiz-generation/model/useQuizGenerationStore.js b/src/features/quiz-generation/model/useQuizGenerationStore.js new file mode 100644 index 0000000..2badc79 --- /dev/null +++ b/src/features/quiz-generation/model/useQuizGenerationStore.js @@ -0,0 +1,217 @@ +import { create } from "zustand"; +import axiosInstance from "#shared/api"; +import { getAccessToken } from "#entities/auth"; + +const apiBaseURL = import.meta.env.VITE_BASE_URL; + +const buildApiUrl = (path) => { + if (!apiBaseURL) return path; + const base = apiBaseURL.endsWith("/") ? apiBaseURL : `${apiBaseURL}/`; + const safePath = path.replace(/^\/+/, ""); + return new URL(safePath, base).toString(); +}; + +let activeController = null; + +export const useQuizGenerationStore = create((set, get) => ({ + quizzes: [], + totalCount: 0, + isLoading: false, + problemSetId: null, + uploadedUrl: null, + error: null, + + reset: () => { + if (activeController) { + activeController.abort(); + activeController = null; + } + set({ + quizzes: [], + totalCount: 0, + isLoading: false, + problemSetId: null, + uploadedUrl: null, + error: null, + }); + }, + + startGeneration: async ({ + requestData, + onFirstChunk, + onComplete, + onError, + }) => { + if (activeController) { + activeController.abort(); + } + + const controller = new AbortController(); + activeController = controller; + + set({ + quizzes: [], + totalCount: 0, + isLoading: true, + problemSetId: null, + uploadedUrl: requestData?.uploadedUrl ?? null, + error: null, + }); + + try { + const accessToken = getAccessToken(); + const response = await fetch(buildApiUrl("/generation"), { + method: "POST", + headers: { + Accept: "text/event-stream", + "Content-Type": "application/json", + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }, + credentials: "include", + body: JSON.stringify(requestData), + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error("문제 생성 요청에 실패했습니다."); + } + + if (!response.body) { + throw new Error("스트리밍 응답을 읽을 수 없습니다."); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let firstChunkHandled = false; + + const handleChunk = (data) => { + const nextProblemSetId = data?.problemSetId; + const nextTotalCount = Number( + data?.totalCount ?? data?.problemCount ?? data?.quizCount ?? 0, + ); + + if (!firstChunkHandled && nextProblemSetId) { + firstChunkHandled = true; + set({ problemSetId: nextProblemSetId }); + if (typeof onFirstChunk === "function") { + onFirstChunk({ + problemSetId: nextProblemSetId, + totalCount: nextTotalCount, + }); + } + } + + if (nextTotalCount > 0 && get().totalCount === 0) { + set({ totalCount: nextTotalCount }); + } + + const quizzesFromChunk = Array.isArray(data?.quiz) ? data.quiz : []; + const hasSingleQuizPayload = + data && + (data.title || data.content || data.question || data.selections); + + if (quizzesFromChunk.length > 0) { + set((state) => ({ + quizzes: [...state.quizzes, ...quizzesFromChunk], + })); + } else if (hasSingleQuizPayload) { + set((state) => ({ quizzes: [...state.quizzes, data] })); + } + }; + + const parseEvent = (rawEvent) => { + const lines = rawEvent.split("\n"); + const dataLines = []; + let eventType = null; + + for (const line of lines) { + if (!line.trim()) continue; + if (line.startsWith("event:")) { + eventType = line.slice(6).trim(); + continue; + } + if (line.startsWith("data:")) { + dataLines.push(line.slice(5).trimStart()); + } + } + + const payload = dataLines.join("\n").trim(); + if (!payload || payload === "[DONE]") return; + + if (eventType === "error") { + set({ isLoading: false, error: payload }); + if (typeof onError === "function") { + onError(new Error(payload)); + } + return; + } + + try { + const data = JSON.parse(payload); + handleChunk(data); + } catch (error) { + console.error("SSE 데이터 파싱 실패:", error); + } + }; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + buffer = buffer.replace(/\r\n/g, "\n"); + + while (buffer.includes("\n\n")) { + const boundaryIndex = buffer.indexOf("\n\n"); + const rawEvent = buffer.slice(0, boundaryIndex); + buffer = buffer.slice(boundaryIndex + 2); + if (rawEvent.trim()) { + parseEvent(rawEvent); + } + } + } + + if (buffer.trim()) { + buffer = buffer.replace(/\r\n/g, "\n"); + parseEvent(buffer); + } + + if (activeController === controller) { + set({ isLoading: false }); + if (typeof onComplete === "function") { + onComplete(get().problemSetId); + } + } + } catch (error) { + if (error?.name !== "AbortError" && activeController === controller) { + set({ isLoading: false, error: error?.message || "알 수 없는 오류" }); + if (typeof onError === "function") { + onError(error); + } + } + } + }, + + loadProblemSet: async (problemSetId) => { + set({ isLoading: true, error: null, problemSetId }); + try { + const res = await axiosInstance.get(`/problem-set/${problemSetId}`); + const data = res.data || {}; + const quizzes = data.quiz || data.quizzes || data.problems || []; + set({ + quizzes, + totalCount: Array.isArray(quizzes) ? quizzes.length : 0, + isLoading: false, + }); + } catch (error) { + set({ + isLoading: false, + error: + error?.response?.data?.message || + error?.message || + "문제집을 불러오지 못했습니다.", + }); + } + }, +})); diff --git a/src/features/quiz-history/index.js b/src/features/quiz-history/index.js new file mode 100644 index 0000000..0e3f602 --- /dev/null +++ b/src/features/quiz-history/index.js @@ -0,0 +1 @@ +export { useQuizHistory } from "./model/useQuizHistory"; diff --git a/src/features/quiz-history/model/useQuizHistory.js b/src/features/quiz-history/model/useQuizHistory.js new file mode 100644 index 0000000..5c7cd98 --- /dev/null +++ b/src/features/quiz-history/model/useQuizHistory.js @@ -0,0 +1,262 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import axiosInstance from "#shared/api"; +import CustomToast from "#shared/toast"; +import { trackQuizHistoryEvents } from "#shared/lib/analytics"; +import { useClickOutside } from "#shared/lib/useClickOutside"; +import { + clearQuizHistory, + readQuizHistory, + removeQuizHistoryRecord, +} from "#shared/lib/quizHistoryStorage"; + +export const useQuizHistory = ({ t, navigate }) => { + const [quizHistory, setQuizHistory] = useState([]); + const [loading, setLoading] = useState(true); + const [explanationLoading, setExplanationLoading] = useState(false); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const startTimeRef = useRef(Date.now()); + + const loadQuizHistory = () => { + try { + const history = readQuizHistory(); + setQuizHistory(history); + return history; + } catch (error) { + console.error(t("퀴즈 기록 불러오기 실패:"), error); + CustomToast.error(t("기록을 불러오는데 실패했습니다.")); + return []; + } finally { + setLoading(false); + } + }; + + const toggleSidebar = () => setIsSidebarOpen((prev) => !prev); + + useEffect(() => { + loadQuizHistory(); + }, []); + + useClickOutside({ + containerId: "sidebar", + triggerId: "menuButton", + onOutsideClick: () => setIsSidebarOpen(false), + }); + + const navigateToExplanation = async (record) => { + if (record.status !== "completed") { + CustomToast.info(t("완료된 퀴즈만 해설을 볼 수 있습니다.")); + return; + } + + trackQuizHistoryEvents.clickViewExplanation( + record.problemSetId, + record.quizLevel, + record.score, + ); + + setExplanationLoading(true); + + try { + if (record.quizData && record.quizData.length > 0) { + const explanationResponse = await axiosInstance.get( + `/explanation/${record.problemSetId}`, + ); + const explanationData = explanationResponse.data; + + const stateData = { + quizzes: record.quizData, + explanation: explanationData, + uploadedUrl: record.uploadedUrl, + }; + + navigate(`/explanation/${record.problemSetId}`, { + state: stateData, + }); + } else { + const quizResponse = await axiosInstance.get( + `/problem-set/${record.problemSetId}`, + ); + const quizData = quizResponse.data; + + const explanationResponse = await axiosInstance.get( + `/explanation/${record.problemSetId}`, + ); + const explanationData = explanationResponse.data; + + const finalQuizzes = quizData.problems || quizData.quizzes || []; + + const stateData = { + quizzes: finalQuizzes, + explanation: explanationData, + uploadedUrl: record.uploadedUrl, + }; + + navigate(`/explanation/${record.problemSetId}`, { + state: stateData, + }); + } + } catch (error) { + console.error(t("해설 데이터 로딩 실패:"), error); + console.error(t("에러 상세 정보:"), { + message: error.message, + response: error.response?.data, + status: error.response?.status, + config: error.config, + }); + CustomToast.error( + t("해설을 불러오는데 실패했습니다. 문제가 삭제되었을 수 있습니다."), + ); + } finally { + setExplanationLoading(false); + } + }; + + const navigateToQuiz = (record) => { + if (record.status === "completed") { + trackQuizHistoryEvents.clickRetryQuiz( + record.problemSetId, + record.quizLevel, + record.score, + ); + } else { + trackQuizHistoryEvents.clickResumeQuiz( + record.problemSetId, + record.quizLevel, + record.questionCount, + ); + } + + navigate(`/quiz/${record.problemSetId}`, { + state: { + uploadedUrl: record.uploadedUrl, + }, + }); + }; + + const deleteQuizRecord = (problemSetId) => { + if (window.confirm(t("이 기록을 삭제하시겠습니까?"))) { + try { + const record = quizHistory.find( + (item) => item.problemSetId === problemSetId, + ); + + trackQuizHistoryEvents.deleteQuizRecord( + problemSetId, + record?.status || "unknown", + record?.quizLevel || "unknown", + ); + + const updatedHistory = removeQuizHistoryRecord(problemSetId); + setQuizHistory(updatedHistory); + CustomToast.success(t("기록이 삭제되었습니다.")); + } catch (error) { + console.error(t("기록 삭제 실패:"), error); + CustomToast.error(t("기록 삭제에 실패했습니다.")); + } + } + }; + + const clearAllHistory = () => { + if ( + window.confirm( + t("모든 기록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."), + ) + ) { + try { + const completed = quizHistory.filter( + (item) => item.status === "completed", + ); + + trackQuizHistoryEvents.clearAllHistory( + quizHistory.length, + completed.length, + ); + + clearQuizHistory(); + setQuizHistory([]); + CustomToast.success(t("모든 기록이 삭제되었습니다.")); + } catch (error) { + console.error(t("전체 기록 삭제 실패:"), error); + CustomToast.error(t("기록 삭제에 실패했습니다.")); + } + } + }; + + const formatDate = (dateString) => { + const date = new Date(dateString); + return date.toLocaleDateString("ko-KR", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; + + const stats = useMemo(() => { + const completed = quizHistory.filter((item) => item.status === "completed"); + const totalQuizzes = quizHistory.length; + const completedQuizzes = completed.length; + const averageScore = + completed.length > 0 + ? Math.round( + completed.reduce((sum, item) => sum + item.score, 0) / + completed.length, + ) + : 0; + + return { + totalQuizzes, + completedQuizzes, + averageScore, + completionRate: + totalQuizzes > 0 + ? Math.round((completedQuizzes / totalQuizzes) * 100) + : 0, + }; + }, [quizHistory]); + + useEffect(() => { + if (!loading && quizHistory.length >= 0) { + trackQuizHistoryEvents.viewHistory( + stats.totalQuizzes, + stats.completedQuizzes, + stats.averageScore, + ); + } + }, [loading, quizHistory.length, stats]); + + useEffect(() => { + return () => { + const timeSpent = Math.round((Date.now() - startTimeRef.current) / 1000); + if (timeSpent > 3) { + trackQuizHistoryEvents.trackTimeSpent(timeSpent, quizHistory.length); + } + }; + }, [quizHistory.length]); + + const handleCreateFromEmpty = () => { + trackQuizHistoryEvents.clickCreateFromEmpty(); + navigate("/"); + }; + + return { + state: { + quizHistory, + loading, + explanationLoading, + isSidebarOpen, + stats, + }, + actions: { + toggleSidebar, + setIsSidebarOpen, + navigateToExplanation, + navigateToQuiz, + deleteQuizRecord, + clearAllHistory, + formatDate, + handleCreateFromEmpty, + }, + }; +}; diff --git a/src/features/quiz-result/index.js b/src/features/quiz-result/index.js new file mode 100644 index 0000000..f288cb8 --- /dev/null +++ b/src/features/quiz-result/index.js @@ -0,0 +1 @@ +export { useQuizResult } from "./model/useQuizResult"; diff --git a/src/features/quiz-result/model/useQuizResult.js b/src/features/quiz-result/model/useQuizResult.js new file mode 100644 index 0000000..2a39931 --- /dev/null +++ b/src/features/quiz-result/model/useQuizResult.js @@ -0,0 +1,92 @@ +import { useEffect, useMemo } from "react"; +import axiosInstance from "#shared/api"; +import { trackQuizEvents, trackResultEvents } from "#shared/lib/analytics"; +import { updateQuizHistoryRecord } from "#shared/lib/quizHistoryStorage"; + +export const useQuizResult = ({ + t, + navigate, + problemSetId, + quizzes, + totalTime, + uploadedUrl, +}) => { + const correctCount = useMemo(() => { + return quizzes.reduce((count, q) => { + const selected = q.selections.find((s) => s.id === q.userAnswer); + return count + (selected?.correct ? 1 : 0); + }, 0); + }, [quizzes]); + + const scorePercent = useMemo(() => { + return quizzes.length + ? Math.round((correctCount / quizzes.length) * 100) + : 0; + }, [quizzes.length, correctCount]); + + const updateQuizHistoryResult = ( + nextProblemSetId, + nextCorrectCount, + totalQuestions, + nextTotalTime, + score + ) => { + try { + updateQuizHistoryRecord(nextProblemSetId, { + status: "completed", + score, + correctCount: nextCorrectCount, + totalQuestions, + totalTime: nextTotalTime, + completedAt: new Date().toISOString(), + quizData: quizzes, + }); + } catch (error) { + console.error(t("퀴즈 결과 기록 업데이트 실패:"), error); + } + }; + + useEffect(() => { + if (problemSetId && quizzes.length > 0) { + trackResultEvents.viewResult( + problemSetId, + correctCount, + quizzes.length, + totalTime + ); + trackQuizEvents.completeQuiz( + problemSetId, + correctCount, + quizzes.length, + totalTime + ); + + updateQuizHistoryResult( + problemSetId, + correctCount, + quizzes.length, + totalTime, + scorePercent + ); + } + }, [problemSetId, correctCount, quizzes.length, totalTime, scorePercent]); + + const getQuizExplanation = async () => { + trackResultEvents.clickExplanation(problemSetId); + + try { + const res = await axiosInstance.get(`/explanation/${problemSetId}`); + const data = res.data; + navigate(`/explanation/${problemSetId}`, { + state: { quizzes, explanation: data, uploadedUrl }, + }); + } catch (err) { + navigate("/"); + } + }; + + return { + state: { correctCount, scorePercent }, + actions: { getQuizExplanation }, + }; +}; diff --git a/src/features/solve-quiz/index.js b/src/features/solve-quiz/index.js new file mode 100644 index 0000000..d4e0b8f --- /dev/null +++ b/src/features/solve-quiz/index.js @@ -0,0 +1 @@ +export { useSolveQuiz } from "./model/useSolveQuiz"; diff --git a/src/features/solve-quiz/model/useSolveQuiz.js b/src/features/solve-quiz/model/useSolveQuiz.js new file mode 100644 index 0000000..7955945 --- /dev/null +++ b/src/features/solve-quiz/model/useSolveQuiz.js @@ -0,0 +1,265 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import axiosInstance from "#shared/api"; +import CustomToast from "#shared/toast"; +import { trackQuizEvents } from "#shared/lib/analytics"; + +const buildTimerLabel = (hours, minutes, seconds) => + `${String(hours).padStart(2, "0")}:` + + `${String(minutes).padStart(2, "0")}:` + + `${String(seconds).padStart(2, "0")}`; + +export const useSolveQuiz = ({ + t, + navigate, + problemSetId, + uploadedUrl, + streamedQuizzes = [], + isStreaming = false, +}) => { + const [quizzes, setQuizzes] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [currentTime, setCurrentTime] = useState("00:00:00"); + const [selectedOption, setSelectedOption] = useState(null); + const [currentQuestion, setCurrentQuestion] = useState(1); + const [showSubmitDialog, setShowSubmitDialog] = useState(false); + const hasStartedRef = useRef(false); + const totalQuestions = quizzes.length; + + useEffect(() => { + if (!problemSetId) { + navigate("/"); + } + }, [problemSetId, navigate]); + + useEffect(() => { + const fetchQuiz = async () => { + try { + const res = await axiosInstance.get(`/problem-set/${problemSetId}`); + const data = res.data; + setQuizzes(data.quiz || []); + if (!hasStartedRef.current) { + trackQuizEvents.startQuiz(problemSetId); + hasStartedRef.current = true; + } + } catch (err) { + navigate("/"); + } finally { + setIsLoading(false); + } + }; + + if (problemSetId && !isStreaming && streamedQuizzes.length === 0) { + fetchQuiz(); + } else if (!problemSetId) { + setIsLoading(false); + } + }, [problemSetId, navigate, isStreaming, streamedQuizzes.length]); + + useEffect(() => { + if (!isStreaming) return; + + if (streamedQuizzes.length === 0) { + setIsLoading(true); + return; + } + + setQuizzes((prev) => { + const prevByNumber = new Map( + prev.map((quiz) => [quiz.number, quiz]) + ); + const merged = streamedQuizzes.map((quiz) => { + const prevQuiz = prevByNumber.get(quiz.number); + if (!prevQuiz) return quiz; + return { + ...quiz, + userAnswer: prevQuiz.userAnswer, + check: prevQuiz.check, + }; + }); + return merged; + }); + + setIsLoading(false); + if (!hasStartedRef.current && problemSetId) { + trackQuizEvents.startQuiz(problemSetId); + hasStartedRef.current = true; + } + }, [streamedQuizzes, isStreaming, problemSetId]); + + useEffect(() => { + let seconds = 0; + let minutes = 0; + let hours = 0; + const timer = setInterval(() => { + seconds++; + if (seconds === 60) { + seconds = 0; + minutes++; + } + if (minutes === 60) { + minutes = 0; + hours++; + } + setCurrentTime(buildTimerLabel(hours, minutes, seconds)); + }, 1000); + return () => clearInterval(timer); + }, []); + + useEffect(() => { + const saved = quizzes[currentQuestion - 1]?.userAnswer; + setSelectedOption(saved && saved !== 0 ? saved : null); + }, [currentQuestion, quizzes]); + + const handleOptionSelect = (id) => { + const currentQuiz = quizzes[currentQuestion - 1]; + const selected = currentQuiz?.selections?.find((s) => s.id === id); + + if (selected) { + trackQuizEvents.selectAnswer( + problemSetId, + currentQuestion, + id, + selected.correct || false + ); + } + + setQuizzes((prev) => + prev.map((q, idx) => + idx === currentQuestion - 1 ? { ...q, userAnswer: id } : q + ) + ); + setSelectedOption(id); + }; + + const handlePrev = () => { + if (currentQuestion > 1) { + const prevQuestion = currentQuestion - 1; + trackQuizEvents.navigateQuestion( + problemSetId, + currentQuestion, + prevQuestion + ); + setCurrentQuestion(prevQuestion); + } + }; + + const handleNext = () => { + if (currentQuestion < totalQuestions) { + const nextQuestion = currentQuestion + 1; + trackQuizEvents.navigateQuestion( + problemSetId, + currentQuestion, + nextQuestion + ); + setCurrentQuestion(nextQuestion); + } + }; + + const handleSubmit = () => { + trackQuizEvents.confirmAnswer(problemSetId, currentQuestion); + + if (currentQuestion === totalQuestions) { + CustomToast.info(t("마지막 문제입니다.")); + return; + } + + const nextQuestion = currentQuestion + 1; + trackQuizEvents.navigateQuestion( + problemSetId, + currentQuestion, + nextQuestion + ); + setCurrentQuestion(nextQuestion); + }; + + const handleCheckToggle = () => { + const currentQuiz = quizzes[currentQuestion - 1]; + const newCheckState = !currentQuiz.check; + + trackQuizEvents.toggleReview(problemSetId, currentQuestion, newCheckState); + + setQuizzes((prev) => + prev.map((q, idx) => + idx === currentQuestion - 1 ? { ...q, check: newCheckState } : q + ) + ); + }; + + const handleFinish = () => { + setShowSubmitDialog(true); + }; + + const handleConfirmSubmit = useCallback(() => { + const unansweredCount = quizzes.filter((q) => q.userAnswer === 0).length; + const reviewCount = quizzes.filter((q) => q.check).length; + const answeredCount = quizzes.length - unansweredCount; + + trackQuizEvents.submitQuiz( + problemSetId, + answeredCount, + quizzes.length, + reviewCount + ); + + navigate(`/result/${problemSetId}`, { + state: { quizzes, totalTime: currentTime, uploadedUrl }, + }); + }, [quizzes, problemSetId, currentTime, uploadedUrl, navigate]); + + const handleCancelSubmit = useCallback(() => { + setShowSubmitDialog(false); + }, []); + + const handleJumpTo = (num) => { + if (num !== currentQuestion) { + trackQuizEvents.navigateQuestion(problemSetId, currentQuestion, num); + } + setCurrentQuestion(num); + }; + + const unansweredCount = useMemo( + () => quizzes.filter((q) => q.userAnswer === 0).length, + [quizzes] + ); + const reviewCount = useMemo( + () => quizzes.filter((q) => q.check).length, + [quizzes] + ); + const answeredCount = quizzes.length - unansweredCount; + + const handleOverlayClick = useCallback((e) => { + if (e.target === e.currentTarget) { + setShowSubmitDialog(false); + } + }, []); + + const currentQuiz = quizzes[currentQuestion - 1] || {}; + + return { + state: { + quizzes, + isLoading, + currentTime, + selectedOption, + currentQuestion, + showSubmitDialog, + totalQuestions, + unansweredCount, + reviewCount, + answeredCount, + currentQuiz, + }, + actions: { + handleOptionSelect, + handlePrev, + handleNext, + handleSubmit, + handleCheckToggle, + handleFinish, + handleConfirmSubmit, + handleCancelSubmit, + handleJumpTo, + handleOverlayClick, + }, + }; +}; diff --git a/src/main.jsx b/src/main.jsx deleted file mode 100644 index b9a1a6d..0000000 --- a/src/main.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.jsx' - -createRoot(document.getElementById('root')).render( - - - , -) diff --git a/src/pages/MakeQuiz/index.jsx b/src/pages/MakeQuiz/index.jsx deleted file mode 100644 index 7ab5001..0000000 --- a/src/pages/MakeQuiz/index.jsx +++ /dev/null @@ -1,849 +0,0 @@ -import { useTranslation } from "i18nexus"; -import Header from "#components/header"; -import Help from "#components/help"; -import axiosInstance from "#shared/api"; -import CustomToast from "#shared/toast"; -import { trackMakeQuizEvents } from "#utils/analytics"; -import Timer from "#utils/timer"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { Document, Page, pdfjs } from "react-pdf"; -import "react-pdf/dist/Page/AnnotationLayer.css"; -import "react-pdf/dist/Page/TextLayer.css"; -import { useNavigate } from "react-router-dom"; -import "./index.css"; -import { OcrButton, RecentChanges } from "./ui"; -import { uploadFileToServer } from "./util/fileUploader"; - -const levelDescriptions = { - RECALL: `순수 암기나 단순 이해를 묻는 문제 - - 예) "대한민국의 수도는 _______이다."`, - - SKILLS: `옳고 그름을 판별하는 문제 - - 예) "지구는 태양 주위를 돈다. (O/X)"`, - - STRATEGIC: `추론, 문제 해결, 자료 해석을 요구하는 문제 - - 예) [전제] 물가가 오르면 화폐 가치는 떨어진다. 현재 물가가 급등했다. - [질문] 이 경우 화폐 가치의 변화로 가장 적절한 것은? - 1. 하락한다 - 2. 상승한다 - 3. 변함없다 - 4. 알 수 없다`, -}; - -const MAX_FILE_SIZE = 30 * 1024 * 1024; - -pdfjs.GlobalWorkerOptions.workerSrc = new URL( - "pdfjs-dist/build/pdf.worker.min.mjs", - import.meta.url -).toString(); - -const levelMapping = { - BLANK: "RECALL", - OX: "SKILLS", - MULTIPLE: "STRATEGIC", -}; - -const defaultType = "MULTIPLE"; - -const MakeQuiz = () => { - const { t } = useTranslation(); - const navigate = useNavigate(); - const [file, setFile] = useState(null); - const [uploadedUrl, setUploadedUrl] = useState(null); - const [isDragging, setIsDragging] = useState(false); - const [questionType, setQuestionType] = useState(() => { - // localStorage에서 저장된 questionType을 불러옴 - const savedType = localStorage.getItem("questionType"); - return savedType || defaultType; - }); // "MULTIPLE", "BLANK", "OX" - const [questionCount, setQuestionCount] = useState(5); - const [isProcessing, setIsProcessing] = useState(false); - const [version, setVersion] = useState(0); - const [isSidebarOpen, setIsSidebarOpen] = useState(false); - const [problemSetId, setProblemSetId] = useState(null); - const [quizLevel, setQuizLevel] = useState(() => { - // localStorage에서 불러온 questionType에 맞는 난이도 설정 - const savedType = localStorage.getItem("questionType"); - return levelMapping[savedType || defaultType]; - }); // 기본 난이도 설정 - const [pageMode, setPageMode] = useState("ALL"); // "ALL" 또는 "CUSTOM" - const [numPages, setNumPages] = useState(null); - const [selectedPages, setSelectedPages] = useState([]); - const [hoveredPage, setHoveredPage] = useState(null); // { pageNumber: number, style: object } - const [visiblePageCount, setVisiblePageCount] = useState(50); // 점진적 로딩을 위한 가시적 페이지 수 - const pdfPreviewRef = useRef(null); - const [showWaitMessage, setShowWaitMessage] = useState(false); // 5초 후 대기 메시지 표시용 - const [uploadElapsedTime, setUploadElapsedTime] = useState(0); // 업로드 경과 시간 - const [generationElapsedTime, setGenerationElapsedTime] = useState(0); // 문제 생성 경과 시간 - const [fileExtension, setFileExtension] = useState(null); // 파일 확장자 - const [showHelp, setShowHelp] = useState(false); - const uploadTimerRef = useRef(null); // 업로드 타이머 - const generationTimerRef = useRef(null); // 문제 생성 타이머 - - // PDF 옵션 메모이제이션 - const pdfOptions = useMemo( - () => ({ - cMapUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/cmaps/`, - cMapPacked: true, - standardFontDataUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/standard_fonts/`, - }), - [] - ); - - - // questionType 변경 시 quizLevel 자동으로 변경 및 localStorage에 저장 - useEffect(() => { - setQuizLevel(levelMapping[questionType]); - // 사용자가 선택한 questionType을 localStorage에 저장 - localStorage.setItem("questionType", questionType); - }, [questionType]); - // Sidebar toggle & click-outside - const toggleSidebar = () => setIsSidebarOpen((prev) => !prev); - useEffect(() => { - const handler = (e) => { - const sidebar = document.getElementById("sidebar"); - const btn = document.getElementById("menuButton"); - if ( - sidebar && - !sidebar.contains(e.target) && - btn && - !btn.contains(e.target) - ) { - setIsSidebarOpen(false); - } - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, []); - - // Drag & Drop - const handleDragOver = (e) => { - e.preventDefault(); - setIsDragging(true); - }; - const handleDragEnter = (e) => { - e.preventDefault(); - setIsDragging(true); - }; - const handleDragLeave = (e) => { - e.preventDefault(); - setIsDragging(false); - }; - const handleDrop = (e) => { - e.preventDefault(); - setIsDragging(false); - if (e.dataTransfer.files.length > 0) { - selectFile(e.dataTransfer.files[0], "drag_drop"); - } - }; - - // File selection - const handleFileInput = (e) => { - if (e.target.files.length > 0) selectFile(e.target.files[0], "click"); - }; - const selectFile = async (f, method = "click") => { - const ext = f.name.split(".").pop().toLowerCase(); - - if (!["ppt", "pptx", "pdf"].includes(ext)) { - CustomToast.error(t("지원하지 않는 파일 형식입니다")); - return; - } - - if (f.size > MAX_FILE_SIZE) { - CustomToast.error( - `파일 크기는 ${MAX_FILE_SIZE / 1024 / 1024}MB를 초과할 수 없습니다.` - ); - return; - } - - // 파일 업로드 시작 추적 - const uploadStartTime = Date.now(); - if (method === "drag_drop") { - trackMakeQuizEvents.dragDropFileUpload(f.name, f.size, ext); - } else { - trackMakeQuizEvents.startFileUpload(f.name, f.size, ext); - } - - // 타이머 시작 - uploadTimerRef.current = new Timer((elapsed) => { - setUploadElapsedTime(elapsed); - }); - uploadTimerRef.current.start(); - - setFileExtension(ext); - setIsProcessing(true); - try { - const uploadedUrl = await uploadFileToServer(f); - setUploadedUrl(uploadedUrl); - setFile(f); - - // 타이머 정지 및 업로드 완료 추적 - const uploadTime = uploadTimerRef.current.stop(); - trackMakeQuizEvents.completeFileUpload(f.name, uploadTime); - } catch (error) { - // 에러 발생 시 타이머 정지 - if (uploadTimerRef.current) { - uploadTimerRef.current.stop(); - } - - const message = - error?.message === "변환 시간 초과" - ? t("파일 변환이 지연되고 있어요. 잠시 후 다시 시도해주세요.") - : error?.response?.data?.message || - error?.message || - t("파일 업로드 중 오류가 발생했습니다. 다시 시도해주세요."); - - CustomToast.error(message); - console.error("파일 업로드 실패:", error); - return; - } finally { - setFileExtension(null); - setIsProcessing(false); - setUploadElapsedTime(0); - } - }; - - // Simulate processing - const generateQuestions = async () => { - if (!uploadedUrl) { - CustomToast.error(t("파일을 먼저 업로드해주세요.")); - return; - } - // questionType은 이미 "MULTIPLE", "OX", "BLANK" 형태로 저장되어 있음 - const apiQuizType = questionType; - - try { - generationTimerRef.current = new Timer((elapsed) => { - setGenerationElapsedTime(elapsed); - }); - generationTimerRef.current.start(); - setIsProcessing(true); - console.log("selectedPages", selectedPages); - const response = await axiosInstance.post(`/generation`, { - uploadedUrl: uploadedUrl, - quizCount: questionCount, - quizType: apiQuizType, - difficultyType: quizLevel, - pageNumbers: selectedPages, - }); - const result = response.data; - console.log(t("생성된 문제 데이터:"), result); - setProblemSetId(result.problemSetId); - setVersion((prev) => prev + 1); - - // 퀴즈 기록을 localStorage에 저장 - saveQuizToHistory(result.problemSetId, file.name); - - // 문제 생성 완료 추적 - const generationTime = generationTimerRef.current.stop(); - trackMakeQuizEvents.completeQuizGeneration( - result.problemSetId, - generationTime - ); - } catch (error) { - if (generationTimerRef.current) { - generationTimerRef.current.stop(); - } - resetAllStates(); - } finally { - setIsProcessing(false); - setGenerationElapsedTime(0); - } - }; - - // 퀴즈 기록을 localStorage에 저장하는 함수 - const saveQuizToHistory = (problemSetId, fileName) => { - try { - const existingHistory = JSON.parse( - localStorage.getItem("quizHistory") || "[]" - ); - - const newQuizRecord = { - problemSetId, - fileName, - fileSize: file.size, - questionCount, - quizLevel, - createdAt: new Date().toISOString(), - uploadedUrl, - status: "created", // created, completed - score: null, - correctCount: null, - totalTime: null, - }; - - // 중복 확인 (같은 problemSetId가 있으면 업데이트하지 않음) - const existingIndex = existingHistory.findIndex( - (item) => item.problemSetId === problemSetId - ); - if (existingIndex === -1) { - existingHistory.unshift(newQuizRecord); // 최신 항목을 맨 앞에 추가 - - // 최대 20개까지만 저장 - if (existingHistory.length > 20) { - existingHistory.splice(20); - } - - localStorage.setItem("quizHistory", JSON.stringify(existingHistory)); - - // 최신 퀴즈 상태 업데이트 - } - } catch (error) { - console.error(t("퀴즈 기록 저장 실패:"), error); - } - }; - - // 최신 퀴즈 정보를 로드하는 함수 - const loadLatestQuiz = () => { - try { - const history = JSON.parse(localStorage.getItem("quizHistory") || "[]"); - if (history.length > 0) { - const latest = history[0]; - - if (!uploadedUrl) { - setProblemSetId(latest.problemSetId); - // 파일 정보도 복원 (가상의 파일 객체 생성) - const virtualFile = { - name: latest.fileName, - size: latest.fileSize, - }; - setFile(virtualFile); - setUploadedUrl(latest.uploadedUrl); - } - } - } catch (error) { - console.error(t("최신 퀴즈 로딩 실패:"), error); - } - }; - - // 5초 후 대기 메시지 표시 - useEffect(() => { - let timer; - if (isProcessing && uploadedUrl && !problemSetId) { - timer = setTimeout(() => { - setShowWaitMessage(true); - }, 5000); // 5초 후 - } else { - setShowWaitMessage(false); - } - - return () => { - if (timer) clearTimeout(timer); - }; - }, [isProcessing, uploadedUrl, problemSetId]); - - // 컴포넌트 마운트 시 최신 퀴즈 로드 - useEffect(() => { - loadLatestQuiz(); - // 페이지 진입 트래킹 - trackMakeQuizEvents.viewMakeQuiz(); - }, []); - - // PDF 페이지 점진적 로딩 - const pageCountToLoad = 50; - const loadInterval = 2500; - useEffect(() => { - if (!numPages || numPages <= pageCountToLoad) return; - - setVisiblePageCount(pageCountToLoad); - - const interval = setInterval(() => { - setVisiblePageCount((prev) => { - const nextCount = prev + pageCountToLoad; - if (nextCount >= numPages) { - clearInterval(interval); - return numPages; - } - return nextCount; - }); - }, loadInterval); - - return () => clearInterval(interval); - }, [numPages]); - - const handleRemoveFile = () => { - if (file) { - trackMakeQuizEvents.deleteFile(file.name); - } - resetAllStates(); - }; - - const resetAllStates = () => { - // 타이머 정리 - if (uploadTimerRef.current) { - uploadTimerRef.current.reset(); - uploadTimerRef.current = null; - } - - setFile(null); - setUploadedUrl(null); - setIsDragging(false); - setQuestionType(defaultType); - setQuestionCount(5); - setIsProcessing(false); - setVersion(0); - setIsSidebarOpen(false); - setProblemSetId(null); - setQuizLevel(levelMapping[defaultType]); - setPageMode("ALL"); - setNumPages(null); - setSelectedPages([]); - setHoveredPage(null); - setVisiblePageCount(100); - setShowWaitMessage(false); - setUploadElapsedTime(0); - setGenerationElapsedTime(0); - setFileExtension(null); - }; - - const handleReCreate = () => { - setProblemSetId(null); - setPageMode("ALL"); - setNumPages(null); - setSelectedPages([]); - setHoveredPage(null); - setVisiblePageCount(100); - setShowWaitMessage(false); - setUploadElapsedTime(0); - setGenerationElapsedTime(0); - }; - - const handleNavigateToQuiz = () => { - trackMakeQuizEvents.navigateToQuiz(problemSetId); - navigate(`/quiz/${problemSetId}`, { - state: { uploadedUrl }, - }); - }; - - const onDocumentLoadSuccess = ({ numPages: nextNumPages }) => { - setNumPages(nextNumPages); - setSelectedPages(Array.from({ length: nextNumPages }, (_, i) => i + 1)); - setPageMode("ALL"); - }; - - const handlePageSelection = (pageNumber) => { - setSelectedPages((prevSelectedPages) => { - if (prevSelectedPages.includes(pageNumber)) { - return prevSelectedPages.filter((p) => p !== pageNumber); - } else { - return [...prevSelectedPages, pageNumber].sort((a, b) => a - b); - } - }); - }; - - const handleSelectAllPages = () => { - if (selectedPages.length === numPages) { - setSelectedPages([]); - } else { - setSelectedPages(Array.from({ length: numPages }, (_, i) => i + 1)); - } - }; - - const handlePageMouseEnter = (e, pageNumber) => { - // 모바일 너비에서는 미리보기 기능을 비활성화 - if (window.innerWidth <= 768) return; - - if (pageMode === "ALL" || !pdfPreviewRef.current) return; - - const containerRect = pdfPreviewRef.current.getBoundingClientRect(); - const itemRect = e.currentTarget.getBoundingClientRect(); - const itemWidth = itemRect.width; - const midpoint = containerRect.left + containerRect.width / 2; - - const PREVIEW_WIDTH = 660; // CSS에 정의된 너비 + 패딩 - const GAP = 10; // 컴포넌트와 미리보기 사이 간격 - - // 수직 위치를 아이템보다 조금 더 높게 조정 (e.g., 100px 위로) - let top = itemRect.top - containerRect.top - 100; - // 단, 그리드 상단 밖으로 벗어나지 않도록 최소 위치를 0으로 설정 - if (top < 0) { - top = 0; - } - - const style = { - top: `${top}px`, - width: `${PREVIEW_WIDTH}px`, - }; - - if (itemRect.left < midpoint) { - // Item is on the left, show preview on the right - style.left = `${itemRect.left - containerRect.left + itemWidth + GAP}px`; - } else { - // Item is on the right, show preview on the left - style.left = `${ - itemRect.left - containerRect.left - PREVIEW_WIDTH - GAP - }px`; - } - - setHoveredPage({ pageNumber, style }); - }; - - const handlePageMouseLeave = () => { - setHoveredPage(null); - }; - - return ( -
-
- -
-
- {/* 파일 업로드 중일 때 */} - {isProcessing && !uploadedUrl ? ( -
-
-
-
- {t("파일 업로드 중...")} - {Math.floor(uploadElapsedTime / 1000)} - {t("초")} -
-
- {fileExtension && fileExtension !== "pdf" && ( -
-
- {fileExtension.toUpperCase()} - {t("파일을 PDF로 변환하고 있어요")} -
- - {t("파일 크기에 따라 시간이 소요될 수 있습니다")} - -
-
- )} -
- ) : !uploadedUrl ? ( - <> -
☁️
-
- {t("파일을 여기에 드래그하세요")} -
-

{t("또는")}

-
- {t("파일 선택하기")} - - -
-

- {t("지원 파일 형식: PPT, PPTX, PDF")} -

- {t("파일 크기 제한:")} {MAX_FILE_SIZE / 1024 / 1024}MB

-

- - ) : ( - <> -
📄
-
{file.name}
- {file.size &&

{(file.size / 1024 / 1024).toFixed(2)} MB

} - - - )} -

-

- {t("파일 page 제한: 선택했을 때")} 150pages 이하 -

- {t("🚨파일은 상업적 목적, AI 학습 목적으로 사용되지 않습니다.")} -

{" "} - {t("24시간 후 자동 삭제되며 별도로 저장, 공유되지 않습니다.")} -

{" "} - {t("생성된 문제의 개수는 간혹 지정한 개수와 맞지 않을 수 있습니다")} -

-
- {/* Options Panel */} - {uploadedUrl && !problemSetId && ( -
-
{t("퀴즈 생성 옵션")}
- {/* 문제 유형 세그먼티드 */} -
- {[ - { key: "MULTIPLE", label: t("객관식") }, - { key: "BLANK", label: t("빈칸 넣기") }, - { key: "OX", label: t("OX 퀴즈") }, - ].map((type) => { - return ( - - ); - })} -
-
- {/* ② 선택한 난이도에 해당하는 설명을 옆에 출력 */} -
-
-                  {levelDescriptions[quizLevel]}
-                
-
-
- {/* 문제 수량 슬라이더 */} -
- - { - const newCount = +e.target.value; - if (questionCount !== newCount) { - trackMakeQuizEvents.changeQuizOption( - "question_count", - newCount - ); - setQuestionCount(newCount); - } - }} - /> -
- -
- {t("특정 페이지를 지정하고 싶으신가요?")} -
-
- -
- - {uploadedUrl && ( -
-
-
- {t("미리보기 및 페이지 선택")} -
- -
- -
-
- {Array.from( - new Array(Math.min(visiblePageCount, numPages)), - (el, index) => ( -
{ - if (pageMode !== "ALL") { - handlePageSelection(index + 1); - } - }} - onMouseEnter={(e) => - handlePageMouseEnter(e, index + 1) - } - > - - -

- {t("페이지")} - {index + 1} -

-
- ) - )} - {visiblePageCount < numPages && ( -
-
-

- {t("더 많은 페이지 로딩 중... (")} - {visiblePageCount}/{numPages}) -

-
- )} -
- - {hoveredPage && ( -
- -
- )} -
- -
- )} -
- )} - {/* ① 문서 미리보기 */} - {uploadedUrl && ( -
-
{t("문제 생성결과")}
-
- {isProcessing ? ( -
-
-

- {t("문제 생성 중...")} - {Math.floor(generationElapsedTime / 1000)} - {t("초")} -

- {showWaitMessage && ( -

- {t("현재 생성중입니다 조금만 더 기다려주세요!")} -

- )} -
- ) : !problemSetId ? ( -

- {t( - "문서를 분석하고 문제를 생성하려면 아래 버튼을 클릭하세요." - )} -

- ) : ( -
-
📝
-
-
- {file.name} - {version > 0 && `.ver${version}`} -
-
-
- - - -
-
- )} -
-
- )} - - {uploadedUrl && !problemSetId && ( -
- -
- )} - - - {showHelp && } -
- - {/* Footer */} -
- © 2025 Q-Asker. All rights reserved. -

- {t("문의 및 피드백")} - : - - {t("구글 폼 링크")} - - , - - inhapj01@gmail.com - -
-
- ); -}; - -export default MakeQuiz; diff --git a/src/pages/MakeQuiz/ui/OcrButton/index.css b/src/pages/MakeQuiz/ui/OcrButton/index.css deleted file mode 100644 index 64e87a1..0000000 --- a/src/pages/MakeQuiz/ui/OcrButton/index.css +++ /dev/null @@ -1,128 +0,0 @@ -.ocr-section { - position: relative; - display: flex; - flex-direction: row; - align-items: center; - font-size: 1.17em; - font-weight: bold; - color: #111827; - width: fit-content; - margin-top: 16px; - margin-left: auto; - margin-right: auto; - gap: 8px; -} - -.ocr-button { - background: #10b981; - color: #fff; - border: none; - padding: 12px 24px; - border-radius: 8px; - cursor: pointer; - font-size: 1rem; - font-weight: 600; - transition: background-color 0.2s ease, transform 0.2s ease; -} - -.ocr-button:hover { - background-color: #059669; - transform: scale(1.02); -} - -/* 말풍선 스타일 */ -.tooltip { - position: relative; - display: inline-block; - cursor: help; -} - -.tooltip .tooltip-text { - visibility: hidden; - width: 280px; - background-color: #333; - color: #fff; - text-align: center; - border-radius: 6px; - padding: 12px; - position: absolute; - z-index: 1; - bottom: 125%; - left: 20%; - margin-left: -60px; - opacity: 0; - transition: opacity 0.3s; - font-size: 12px; - line-height: 1.4; -} - -.tooltip .tooltip-text::after { - content: ""; - position: absolute; - top: 100%; - left: 25%; - margin-left: -5px; - border-width: 5px; - border-style: solid; - border-color: #333 transparent transparent transparent; -} - -/* 말풍선 아이콘에 호버할 때 */ -.tooltip:hover .tooltip-text { - visibility: visible; - opacity: 1; -} - -/* OCR 버튼에 호버할 때 인접한 말풍선 보이기 */ -.ocr-button:hover + .tooltip .tooltip-text { - visibility: visible; - opacity: 1; -} - -/* 말풍선 아이콘 */ -.tooltip::before { - content: "💬"; - font-size: 16px; - opacity: 0.7; - transition: opacity 0.3s; -} - -.tooltip:hover::before { - opacity: 1; -} - -/* 모바일에서 업로드 버튼 컨테이너 */ -.upload-buttons-container { - flex-direction: column; - gap: 12px; - margin: 16px 0; -} - -@media (max-width: 768px) { - .ocr-section { - flex-direction: column; - align-items: center; - gap: 0px; - } - - .tooltip { - display: none; - } - - .tooltip .tooltip-text { - width: 250px; - left: 15%; - margin-left: -50px; - bottom: 110%; - font-size: 11px; - padding: 10px; - } - - .tooltip .tooltip-text::after { - left: 30%; - } - - .tooltip::before { - font-size: 14px; - } -} diff --git a/src/pages/MakeQuiz/ui/OcrButton/index.jsx b/src/pages/MakeQuiz/ui/OcrButton/index.jsx deleted file mode 100644 index 62b8e2a..0000000 --- a/src/pages/MakeQuiz/ui/OcrButton/index.jsx +++ /dev/null @@ -1,21 +0,0 @@ -import { useTranslation } from "i18nexus"; -import "./index.css"; - -const OcrButton = () => { - const { t } = useTranslation(); - return ( -
-

{t("텍스트가 선택되지 않는 PDF는 OCR 변환이 필요합니다!")}

- -
- ); -}; - -export default OcrButton; diff --git a/src/pages/MakeQuiz/ui/RecentChanges/index.jsx b/src/pages/MakeQuiz/ui/RecentChanges/index.jsx deleted file mode 100644 index 2484389..0000000 --- a/src/pages/MakeQuiz/ui/RecentChanges/index.jsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useEffect, useState } from "react"; -import { useTranslation } from "i18nexus"; -import axiosInstance from "#shared/api"; -import "./index.css"; - -const RecentChanges = () => { - const { t } = useTranslation(); - const [changes, setChanges] = useState([]); - - useEffect(() => { - const fetchUpdates = async () => { - try { - const res = await axiosInstance.get("/updateLog"); - - const data = res.data; - - setChanges(data.updateLogs || []); - } catch (err) { - console.error("변경사항 로드 실패:", err); - } - }; - - fetchUpdates(); - }, []); - - const formatDate = (isoString) => { - const date = new Date(isoString); - return new Intl.DateTimeFormat("ko-KR", { - timeZone: "Asia/Seoul", - year: "numeric", - month: "2-digit", - day: "2-digit", - }) - .format(date) - .replace(/\. /g, ".") - .replace(/\.$/, ""); - }; - - return ( -
-

{t("최근 변경사항")}

-
    - {changes.map((log, index) => ( -
  • - {formatDate(log.dateTime)} - {t(log.updateText)} -
  • - ))} -
-
- ); -}; - -export default RecentChanges; diff --git a/src/pages/MakeQuiz/ui/index.js b/src/pages/MakeQuiz/ui/index.js deleted file mode 100644 index aaf7290..0000000 --- a/src/pages/MakeQuiz/ui/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { default as OcrButton } from "./OcrButton"; -export { default as RecentChanges } from "./RecentChanges"; diff --git a/src/pages/QuizHistory.jsx b/src/pages/QuizHistory.jsx deleted file mode 100644 index f7beb5d..0000000 --- a/src/pages/QuizHistory.jsx +++ /dev/null @@ -1,565 +0,0 @@ -import { useTranslation } from "i18nexus"; -import Header from "#components/header"; -import axiosInstance from "#shared/api"; -import CustomToast from "#shared/toast"; -import { trackQuizHistoryEvents } from "#utils/analytics"; -import React, { useEffect, useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import "./QuizHistory.css"; - -const QuizHistory = () => { - const { t } = useTranslation(); - const navigate = useNavigate(); - const [quizHistory, setQuizHistory] = useState([]); - const [loading, setLoading] = useState(true); - const [explanationLoading, setExplanationLoading] = useState(false); - const [isSidebarOpen, setIsSidebarOpen] = useState(false); - - // 체류 시간 추적을 위한 ref - const startTimeRef = useRef(Date.now()); - - // 퀴즈 기록 불러오기 - const loadQuizHistory = () => { - try { - const history = JSON.parse(localStorage.getItem("quizHistory") || "[]"); - console.log(t("=== 퀴즈 히스토리 전체 데이터 ===")); - console.log(t("전체 기록 배열:"), history); - console.log(t("총 기록 개수:"), history.length); - - // 각 기록 상세 정보 출력 - history.forEach((record, index) => { - console.log(`--- 기록 ${index + 1} ---`); - console.log(t("문제셋 ID:"), record.problemSetId); - console.log(t("파일명:"), record.fileName); - console.log(t("문제 개수:"), record.questionCount); - console.log(t("퀴즈 레벨:"), record.quizLevel); - console.log(t("점수:"), record.score); - console.log(t("상태:"), record.status); - console.log(t("생성일:"), record.createdAt); - console.log(t("완료일:"), record.completedAt); - console.log(t("업로드 URL:"), record.uploadedUrl); - console.log(t("퀴즈 데이터 존재 여부:"), !!record.quizData); - console.log(t("퀴즈 데이터 길이:"), record.quizData?.length || 0); - if (record.quizData) { - console.log(t("퀴즈 데이터:"), record.quizData); - } - console.log(t("전체 데이터:"), record); - console.log("------------------"); - }); - - setQuizHistory(history); - return history; - } catch (error) { - console.error(t("퀴즈 기록 불러오기 실패:"), error); - CustomToast.error(t("기록을 불러오는데 실패했습니다.")); - return []; - } finally { - setLoading(false); - } - }; - - // 사이드바 토글 - const toggleSidebar = () => setIsSidebarOpen((prev) => !prev); - - // 페이지 로드 시 기록 불러오기 - useEffect(() => { - loadQuizHistory(); - }, []); - - // 사이드바 외부 클릭 감지 - useEffect(() => { - const handler = (e) => { - const sidebar = document.getElementById("sidebar"); - const btn = document.getElementById("menuButton"); - if ( - sidebar && - !sidebar.contains(e.target) && - btn && - !btn.contains(e.target) - ) { - setIsSidebarOpen(false); - } - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, []); - - // 해설 페이지로 이동 - const navigateToExplanation = async (record) => { - if (record.status !== "completed") { - CustomToast.info(t("완료된 퀴즈만 해설을 볼 수 있습니다.")); - return; - } - - // 해설 보기 버튼 클릭 추적 - trackQuizHistoryEvents.clickViewExplanation( - record.problemSetId, - record.quizLevel, - record.score - ); - - console.log(t("=== 해설 페이지 이동 시작 ===")); - console.log(t("선택된 기록:"), record); - - setExplanationLoading(true); - - try { - // 저장된 퀴즈 데이터가 있는지 확인 - if (record.quizData && record.quizData.length > 0) { - console.log(t("저장된 퀴즈 데이터 사용:")); - console.log(t("퀴즈 데이터:"), record.quizData); - console.log(t("퀴즈 데이터 길이:"), record.quizData.length); - - // 해설 데이터만 API로 가져오기 - console.log(`API 호출: /explanation/${record.problemSetId}`); - const explanationResponse = await axiosInstance.get( - `/explanation/${record.problemSetId}` - ); - const explanationData = explanationResponse.data; - console.log(t("해설 데이터:"), explanationData); - - const stateData = { - quizzes: record.quizData, // 저장된 퀴즈 데이터 사용 (사용자 답안 포함) - explanation: explanationData, - uploadedUrl: record.uploadedUrl, - }; - console.log(t("해설 페이지로 전달할 state 데이터:"), stateData); - - // 해설 페이지로 이동 - navigate(`/explanation/${record.problemSetId}`, { - state: stateData, - }); - } else { - console.log(t("저장된 퀴즈 데이터가 없음. API로 데이터 가져오기")); - - // 1. 문제 데이터 가져오기 - console.log(`API 호출: /problem-set/${record.problemSetId}`); - const quizResponse = await axiosInstance.get( - `/problem-set/${record.problemSetId}` - ); - const quizData = quizResponse.data; - console.log(t("퀴즈 데이터 응답:"), quizResponse); - console.log(t("퀴즈 데이터:"), quizData); - - // 2. 해설 데이터 가져오기 - console.log(`API 호출: /explanation/${record.problemSetId}`); - const explanationResponse = await axiosInstance.get( - `/explanation/${record.problemSetId}` - ); - const explanationData = explanationResponse.data; - console.log(t("해설 데이터 응답:"), explanationResponse); - console.log(t("해설 데이터:"), explanationData); - - // 3. 최종 전달할 데이터 확인 - const finalQuizzes = quizData.problems || quizData.quizzes || []; - console.log(t("최종 퀴즈 배열:"), finalQuizzes); - console.log(t("퀴즈 배열 길이:"), finalQuizzes.length); - - const stateData = { - quizzes: finalQuizzes, - explanation: explanationData, - uploadedUrl: record.uploadedUrl, - }; - console.log(t("해설 페이지로 전달할 state 데이터:"), stateData); - - // 4. 해설 페이지로 이동 - navigate(`/explanation/${record.problemSetId}`, { - state: stateData, - }); - } - } catch (error) { - console.error(t("해설 데이터 로딩 실패:"), error); - console.error(t("에러 상세 정보:"), { - message: error.message, - response: error.response?.data, - status: error.response?.status, - config: error.config, - }); - CustomToast.error( - t("해설을 불러오는데 실패했습니다. 문제가 삭제되었을 수 있습니다.") - ); - } finally { - setExplanationLoading(false); - } - }; - - // 퀴즈 다시 풀기 (문제 생성 페이지로 이동) - const navigateToQuiz = (record) => { - // 퀴즈 상태에 따라 다른 이벤트 추적 - if (record.status === "completed") { - // 완료된 퀴즈 다시 풀기 - trackQuizHistoryEvents.clickRetryQuiz( - record.problemSetId, - record.quizLevel, - record.score - ); - } else { - // 미완료 퀴즈 이어서 풀기 - trackQuizHistoryEvents.clickResumeQuiz( - record.problemSetId, - record.quizLevel, - record.questionCount - ); - } - - navigate(`/quiz/${record.problemSetId}`, { - state: { - uploadedUrl: record.uploadedUrl, - }, - }); - }; - - // 기록 삭제 - const deleteQuizRecord = (problemSetId) => { - if (window.confirm(t("이 기록을 삭제하시겠습니까?"))) { - try { - const record = quizHistory.find( - (item) => item.problemSetId === problemSetId - ); - - // 삭제 이벤트 추적 - trackQuizHistoryEvents.deleteQuizRecord( - problemSetId, - record?.status || "unknown", - record?.quizLevel || "unknown" - ); - - const updatedHistory = quizHistory.filter( - (item) => item.problemSetId !== problemSetId - ); - localStorage.setItem("quizHistory", JSON.stringify(updatedHistory)); - setQuizHistory(updatedHistory); - CustomToast.success(t("기록이 삭제되었습니다.")); - } catch (error) { - console.error(t("기록 삭제 실패:"), error); - CustomToast.error(t("기록 삭제에 실패했습니다.")); - } - } - }; - - // 모든 기록 삭제 - const clearAllHistory = () => { - if ( - window.confirm( - t("모든 기록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.") - ) - ) { - try { - const completed = quizHistory.filter( - (item) => item.status === "completed" - ); - - // 전체 삭제 이벤트 추적 - trackQuizHistoryEvents.clearAllHistory( - quizHistory.length, - completed.length - ); - - localStorage.removeItem("quizHistory"); - setQuizHistory([]); - CustomToast.success(t("모든 기록이 삭제되었습니다.")); - } catch (error) { - console.error(t("전체 기록 삭제 실패:"), error); - CustomToast.error(t("기록 삭제에 실패했습니다.")); - } - } - }; - - // 날짜 포맷팅 - const formatDate = (dateString) => { - const date = new Date(dateString); - return date.toLocaleDateString("ko-KR", { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); - }; - - // 통계 계산 - const getStats = () => { - const completed = quizHistory.filter((item) => item.status === "completed"); - const totalQuizzes = quizHistory.length; - const completedQuizzes = completed.length; - const averageScore = - completed.length > 0 - ? Math.round( - completed.reduce((sum, item) => sum + item.score, 0) / - completed.length - ) - : 0; - - const stats = { - totalQuizzes, - completedQuizzes, - averageScore, - completionRate: - totalQuizzes > 0 - ? Math.round((completedQuizzes / totalQuizzes) * 100) - : 0, - }; - - console.log(t("=== 퀴즈 통계 정보 ===")); - console.log(t("전체 퀴즈 수:"), stats.totalQuizzes); - console.log(t("완료된 퀴즈 수:"), stats.completedQuizzes); - console.log(t("평균 점수:"), stats.averageScore); - console.log(t("완료율:"), stats.completionRate + "%"); - console.log(t("완료된 퀴즈 배열:"), completed); - - return stats; - }; - - const stats = getStats(); - - // 페이지 진입 및 체류 시간 추적 - useEffect(() => { - if (!loading && quizHistory.length >= 0) { - // 페이지 진입 이벤트 추적 - trackQuizHistoryEvents.viewHistory( - stats.totalQuizzes, - stats.completedQuizzes, - stats.averageScore - ); - } - }, [loading, stats.totalQuizzes, stats.completedQuizzes, stats.averageScore]); - - // 페이지 떠날 때 체류 시간 추적 - useEffect(() => { - return () => { - const timeSpent = Math.round((Date.now() - startTimeRef.current) / 1000); - if (timeSpent > 3) { - // 3초 이상 머문 경우만 추적 - trackQuizHistoryEvents.trackTimeSpent(timeSpent, quizHistory.length); - } - }; - }, [quizHistory.length]); - - const handleCreateFromEmpty = () => { - trackQuizHistoryEvents.clickCreateFromEmpty(); - navigate("/"); - }; - - if (loading) { - return ( - <> -
- -
-
-
-

{t("기록을 불러오는 중...")}

-
-
- - ); - } - - return ( - <> -
- -
-
-
-
-

{t("내 퀴즈 기록")}

-

{t("지금까지 만들고 푼 퀴즈들을 확인해보세요")}

-
- - {quizHistory.length > 0 && ( -
- -
- )} -
- - {/* 통계 섹션 */} - {quizHistory.length > 0 && ( -
-
-
-
📝
-
-
{stats.totalQuizzes}
-
{t("총 퀴즈 수")}
-
-
- -
-
-
-
{stats.completedQuizzes}
-
{t("완료한 퀴즈")}
-
-
- -
-
📊
-
-
{stats.completionRate}%
-
{t("완료율")}
-
-
- -
-
🏆
-
-
- {stats.averageScore} - {t("점")} -
-
{t("평균 점수")}
-
-
-
-
- )} - - {/* 퀴즈 보관 안내 */} - {quizHistory.length > 0 && ( -
-
- 📋 -

{t("퀴즈 보관 정책")}

-
-
- {t("• 퀴즈 기록은 최대")} - {t("20개")} - {t("까지 자동으로 저장됩니다")} -
- {t("• 생성된 퀴즈는")}{" "} - {t("24시간 후 서버에서 자동 삭제")} - {t("되어 해설을 볼 수\n 없게 됩니다")} -
- {t( - "• 중요한 퀴즈는 생성 후 24시간 내에 완료하여 기록을\n 남겨두시기 바랍니다" - )} -
-
- )} - - {/* 기록 목록 */} -
- {quizHistory.length === 0 ? ( -
-
📋
-

{t("아직 만든 퀴즈가 없습니다")}

-

{t("퀴즈를 만들어서 문제를 풀어보세요!")}

- -
- ) : ( -
- {quizHistory.map((record) => ( -
-
-
- 📄 - - {record.fileName} - - - {record.status === "completed" - ? t("완료") - : t("미완료")} - -
- -
- - 📝 {record.questionCount} - {t("문제")} - - - 🎯 {record.quizLevel} - - {record.status === "completed" && ( - <> - - 🏆 {record.score} - {t("점 (")} - {record.correctCount}/{record.totalQuestions}) - - - ⏱️ {record.totalTime} - - - )} -
- -
-
- {t("생성:")} - {formatDate(record.createdAt)} -
- {record.completedAt && ( -
- {t("완료:")} - {formatDate(record.completedAt)} -
- )} -
-
- -
- {record.status === "completed" ? ( - <> - - - - ) : ( - - )} - -
-
- ))} -
- )} -
-
-
- - ); -}; - -export default QuizHistory; diff --git a/src/pages/QuizResult.jsx b/src/pages/QuizResult.jsx deleted file mode 100644 index 36c0614..0000000 --- a/src/pages/QuizResult.jsx +++ /dev/null @@ -1,190 +0,0 @@ -import { useTranslation } from "i18nexus";import axiosInstance from "#shared/api"; -import { trackQuizEvents, trackResultEvents } from "#utils/analytics"; -import React, { useEffect, useState } from "react"; -import { useLocation, useNavigate, useParams } from "react-router-dom"; -import "./QuizResult.css"; - -const QuizResult = () => {const { t } = useTranslation(); - const { state } = useLocation(); - const navigate = useNavigate(); - const { problemSetId } = useParams(); - const { quizzes = [], totalTime = "00:00:00", uploadedUrl } = state || {}; - const [explanation, setExplanation] = useState(null); - - const getQuizExplanation = async () => { - // 해설 보기 버튼 클릭 추적 - trackResultEvents.clickExplanation(problemSetId); - - try { - const res = await axiosInstance.get(`/explanation/${problemSetId}`); - const data = res.data; - console.log(data); - setExplanation(data); - navigate(`/explanation/${problemSetId}`, { - state: { quizzes, explanation: data, uploadedUrl } - }); - } catch (err) { - navigate("/"); - } - }; - // ─── 점수 계산 ─── - // 각 문제마다 사용자가 고른 답안이 correct인지 검사 - const correctCount = quizzes.reduce((count, q) => { - const selected = q.selections.find((s) => s.id === q.userAnswer); - return count + (selected?.correct ? 1 : 0); - }, 0); - - // 백분율(소수 없이 정수로 반올림) - const scorePercent = quizzes.length ? - Math.round(correctCount / quizzes.length * 100) : - 0; - - // 결과 페이지 진입 추적 - useEffect(() => { - if (problemSetId && quizzes.length > 0) { - trackResultEvents.viewResult( - problemSetId, - correctCount, - quizzes.length, - totalTime - ); - trackQuizEvents.completeQuiz( - problemSetId, - correctCount, - quizzes.length, - totalTime - ); - - // 퀴즈 완료 기록을 localStorage에 업데이트 - updateQuizHistoryResult( - problemSetId, - correctCount, - quizzes.length, - totalTime, - scorePercent - ); - } - }, [problemSetId, correctCount, quizzes.length, totalTime, scorePercent]); - - // 퀴즈 완료 기록을 localStorage에 업데이트하는 함수 - const updateQuizHistoryResult = ( - problemSetId, - correctCount, - totalQuestions, - totalTime, - score) => - { - try { - const existingHistory = JSON.parse( - localStorage.getItem("quizHistory") || "[]" - ); - - const existingIndex = existingHistory.findIndex( - (item) => item.problemSetId === problemSetId - ); - if (existingIndex !== -1) { - // 기존 기록 업데이트 + 퀴즈 데이터도 함께 저장 - existingHistory[existingIndex] = { - ...existingHistory[existingIndex], - status: "completed", - score, - correctCount, - totalQuestions, - totalTime, - completedAt: new Date().toISOString(), - quizData: quizzes // 실제 퀴즈 데이터 저장 (문제, 선택지, 사용자 답안 포함) - }; - - console.log(t("=== 퀴즈 완료 데이터 저장 ===")); - console.log(t("문제셋 ID:"), problemSetId); - console.log(t("저장할 퀴즈 데이터:"), quizzes); - console.log(t("업데이트된 히스토리:"), existingHistory[existingIndex]); - - localStorage.setItem("quizHistory", JSON.stringify(existingHistory)); - } - } catch (error) { - console.error(t("퀴즈 결과 기록 업데이트 실패:"), error); - } - }; - // ───────────────── - - return ( -
-
- {/* 문제 수 아이템 */} -
- 📋 -
- {t("문제 수")} - {quizzes.length}{t("개")} -
-
- - {/* 걸린 시간 아이템 */} -
- ⏱️ -
- {t("걸린 시간")} - {totalTime} -
-
- - {/* 점수 아이템 */} -
- 🏆 -
- {t("점수")} - {scorePercent}{t("점")} -
-
-
- -
-
- {quizzes.map((q) => { - const userAns = q.userAnswer; - const selection = q.selections.find((s) => s.id === userAns) || {}; - const isCorrect = selection.correct === true; - const correctSelection = - q.selections.find((s) => s.correct === true) || {}; - - return ( -
- -
- {q.number}. {q.title} -
- -
{t("선택한 답:")} - {userAns === 0 ? t("입력 X") : selection.content} -
- - {!isCorrect && -
{t("정답 답안:")} - {correctSelection.content} -
- } - -
- - {isCorrect ? t("정답") : t("오답")} -
-
); - - })} -
-
- - -
); - -}; - -export default QuizResult; \ No newline at end of file diff --git a/src/pages/login-redirect/index.jsx b/src/pages/login-redirect/index.jsx new file mode 100644 index 0000000..d53aa8f --- /dev/null +++ b/src/pages/login-redirect/index.jsx @@ -0,0 +1,12 @@ +import { useTranslation } from "i18nexus";import React from "react"; +import { useNavigate } from "react-router-dom"; +import { useLoginRedirect } from "#features/auth"; + +const LoginRedirect = () => {const { t } = useTranslation(); + const navigate = useNavigate(); + useLoginRedirect({ navigate }); + + return
{t("로그인 처리 중...")}
; +}; + +export default LoginRedirect; \ No newline at end of file diff --git a/src/pages/login-select/index.css b/src/pages/login-select/index.css new file mode 100644 index 0000000..8b6ef0d --- /dev/null +++ b/src/pages/login-select/index.css @@ -0,0 +1,64 @@ +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: #f5f7fb; + padding: 24px; +} + +.login-card { + width: min(420px, 100%); + background: #ffffff; + border-radius: 16px; + padding: 32px; + box-shadow: 0 12px 30px rgba(24, 32, 56, 0.12); + display: flex; + flex-direction: column; + gap: 16px; + text-align: center; +} + +.login-actions { + display: flex; + flex-direction: column; + gap: 12px; +} + +.login-button { + text-decoration: none; + border-radius: 12px; + padding: 12px 16px; + background: #4f46e5; + color: #ffffff; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.login-button:hover { + background: #4338ca; +} + +.login-button--kakao { + background: #fee500; + color: #191919; +} + +.login-button--kakao:hover { + background: #f5dc00; +} + +.login-button--google { + background: #ffffff; + color: #1f2937; + border: 1px solid #e5e7eb; +} + +.login-button--google:hover { + background: #f9fafb; +} diff --git a/src/pages/login-select/index.jsx b/src/pages/login-select/index.jsx new file mode 100644 index 0000000..40bf5cb --- /dev/null +++ b/src/pages/login-select/index.jsx @@ -0,0 +1,32 @@ +import { useTranslation } from "i18nexus"; +import React from "react"; +import "./index.css"; +import Logo from "#shared/ui/logo"; + +const LoginSelect = () => { + const { t } = useTranslation(); + const baseUrl = import.meta.env.VITE_BASE_URL || ""; + const kakaoLoginUrl = `${baseUrl}/oauth2/authorization/kakao`; + const googleLoginUrl = `${baseUrl}/oauth2/authorization/google`; + + return ( + + ); +}; + +export default LoginSelect; diff --git a/src/pages/MakeQuiz/index.css b/src/pages/make-quiz/index.css similarity index 70% rename from src/pages/MakeQuiz/index.css rename to src/pages/make-quiz/index.css index 002e1e8..023460e 100644 --- a/src/pages/MakeQuiz/index.css +++ b/src/pages/make-quiz/index.css @@ -101,6 +101,42 @@ } .hint { color: #6b7280; + display: flex; + justify-content: center; +} +.hint-list { + list-style: none; + padding: 0; + margin: 8px auto 0; + display: grid; + gap: 6px; + text-align: left; +} +.hint-list li { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: baseline; + justify-content: flex-start; +} +.hint-label { + min-width: 72px; + font-size: 12px; + letter-spacing: 0.2px; +} +.hint-value { + font-weight: 600; + color: #374151; +} +.hint-note { + font-size: 12px; + color: #9ca3af; +} +.hint-subtext { + margin: 8px 0 0; + color: #9ca3af; + font-size: 0.85rem; + line-height: 1.4; } .file-icon { font-size: 48px; @@ -119,17 +155,22 @@ /* ──────────────────────────────────── */ /* 옵션 패널 공통 */ .options-panel { - background: white; - border-radius: 8px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - padding: 24px; margin: 32px 0; } -.options-title { - font-size: 18px; - margin-bottom: 24px; - color: #111827; - font-weight: 600; + +.option-section { + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 20px; +} + +.option-section + .option-section { + margin-top: 20px; +} + +.option-section .section-title { + margin-top: 0; } /* ──────────────────────────────────── */ @@ -166,6 +207,23 @@ margin-bottom: 12px; font-weight: 500; color: #374151; + font-size: 14px; +} +.slider-control .count-badge { + margin-left: 8px; +} + +.count-badge, +.preview-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 12px; + border-radius: 999px; + background: #eef2ff; + color: #3730a3; + font-weight: 700; + font-size: 14px; } .slider-control input[type="range"] { width: 100%; @@ -222,6 +280,17 @@ text-decoration: underline; } +.footer .policy-link { + color: inherit; + font-size: 0.9em; + font-weight: 500; +} + +.footer .policy-link:hover { + color: inherit; + text-decoration: none; +} + .upload-section:hover { border-color: #8b5cf6; } @@ -295,9 +364,23 @@ margin: 8px 0; } -.page-title, +.file-meta { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-wrap: wrap; +} + +.file-size { + font-size: 0.95rem; + color: #6b7280; + margin: 0; +} + +.section-title, .level-title { - font-size: 16px; + font-size: 18px; font-weight: 600; color: #111827; margin: 16px 0; @@ -426,8 +509,14 @@ display: flex; justify-content: center; align-items: center; /* 버튼 높이를 맞추기 위해 추가 */ - margin-top: 24px; gap: 1rem; + flex-direction: column; +} +.action-guide { + margin: 0.25rem 0 0; + color: #6b7280; + font-size: 14px; + text-align: center; } .connected-problem { margin-top: 24px; @@ -435,7 +524,6 @@ .problem-card { width: 100%; - max-width: 1200px; background: white; border-radius: 8px; padding: 1.5rem; @@ -445,6 +533,7 @@ gap: 1.5rem; transition: box-shadow 0.2s; flex-wrap: wrap; /* Allow items to wrap on smaller screens */ + box-sizing: border-box; } .problem-card:hover { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); @@ -456,12 +545,15 @@ } .problem-details { flex-grow: 0; + min-width: 0; } .problem-title { margin: 0 0 0.25rem; font-size: 1.1rem; color: #111827; font-weight: 600; + overflow-wrap: anywhere; + word-break: break-word; } .problem-details p { margin: 0; @@ -482,6 +574,8 @@ flex-shrink: 0; align-items: center; margin-left: auto; + flex-wrap: wrap; + max-width: 100%; } .btn { @@ -533,12 +627,6 @@ .document-preview { margin-top: 32px; } -.document-title { - font-size: 20px; - margin-bottom: 16px; - color: #111827; - font-weight: 600; -} .preview-content { background: #f9fafb; @@ -551,6 +639,15 @@ text-align: center; border: 1px solid #e5e7eb; } + +.option-section .preview-content { + background: #ffffff; + padding: 20px; +} + +.option-section .action-buttons { + margin-top: 20px; +} .preview-content p { color: #6b7280; margin: 0; @@ -572,7 +669,83 @@ display: flex; align-items: center; gap: 16px; - margin-top: 24px; +} + +.page-decide.page-decide-custom { + flex-direction: column; + align-items: flex-start; + gap: 12px; +} + +.page-range-panel { + width: 100%; + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 16px; + box-sizing: border-box; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + text-align: center; +} + +.selected-count { + display: flex; + align-items: center; + gap: 6px; +} + +.page-range-panel * { + box-sizing: border-box; +} + +.page-range-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; + width: 100%; +} + +.page-title-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +.page-input-label { + color: #374151; + font-weight: 500; + text-align: left; +} + +.page-range-separator { + color: #6b7280; + text-align: center; +} + +.page-input-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + flex: 1 1 320px; + text-align: center; +} + +.page-input-controls { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: nowrap; +} + +.preview-actions { + flex: 1 1 320px; } .page-decide input[type="number"] { @@ -595,11 +768,6 @@ color: #9ca3af; } -.page-decide span { - color: #374151; - font-weight: 500; -} - .page-decide select { padding: 8px 12px; font-size: 1rem; @@ -616,13 +784,35 @@ outline: 2px solid #c7d2fe; } +.page-decide button { + padding: 8px 14px; + font-size: 0.95rem; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f3f4f6; + color: #374151; + cursor: pointer; + transition: background-color 0.2s ease, border-color 0.2s ease, + color 0.2s ease; +} + +.page-decide button:hover { + background-color: #e5e7eb; +} + +.page-decide button:disabled { + background-color: #e5e7eb; + color: #9ca3af; + cursor: not-allowed; + border-color: #e5e7eb; +} + /* ──────────────────────────────────── */ /* 난이도 설정 */ .level-selector-row { display: flex; align-items: flex-start; gap: 1rem; - margin-bottom: 1rem; } .level-selector-row select { @@ -649,6 +839,78 @@ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); flex-grow: 1; } + +/* ──────────────────────────────────── */ +/* 문제 예시 카드 (SolveQuiz 스타일 참고) */ +.quiz-example-card { + width: 100%; + background: #ffffff; + border-radius: 10px; + border: 1px solid #d7dfe8; + padding: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + box-sizing: border-box; + max-width: 100%; +} + +.quiz-example-title { + font-size: 13px; + font-weight: 600; + color: #4a5568; + margin-bottom: 8px; +} + +.quiz-example-question { + background-color: #e6ebf1; + border-radius: 8px; + padding: 10px 12px; +} + +.quiz-example-question-text { + margin: 0; + font-size: 13px; + line-height: 1.5; + white-space: pre-wrap; + word-break: keep-all; +} + +.quiz-example-options { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 10px; +} + +.quiz-example-option { + display: flex; + align-items: center; + background-color: #ffffff; + border-radius: 8px; + padding: 8px 10px; + border: 1px solid #d7dfe8; +} + +.quiz-example-option-index { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background-color: #f0f3f5; + color: #374151; + font-weight: 600; + margin-right: 10px; + flex-shrink: 0; + font-size: 12px; +} + +.quiz-example-option-text { + font-size: 13px; + line-height: 1.5; + white-space: pre-wrap; + word-break: keep-all; +} .level-description::-webkit-scrollbar { width: 8px; } @@ -681,16 +943,53 @@ .pdf-preview-header { display: flex; justify-content: space-between; - align-items: center; + align-items: flex-start; margin-bottom: 16px; } +.preview-actions { + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + margin-left: auto; + max-width: 100%; +} + +.preview-actions .select-all-button { + width: auto; + flex: 0 0 auto; +} + +.preview-title-group { + display: flex; + flex-direction: column; + gap: 4px; +} + .preview-title { margin: 0; font-size: 16px; font-weight: 600; } +.preview-subtitle { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + color: #374151; + font-weight: 500; + padding-left: 5px; + padding-bottom: 5px; +} + +.preview-subtitle-text { + font-weight: 600; + color: #111827; +} + .pdf-preview-header button { background: #6366f1; color: white; @@ -700,11 +999,66 @@ cursor: pointer; } +.page-decide .select-all-button { + background: #eef2ff; + color: #4338ca; + border-color: #c7d2fe; +} + +.page-decide .select-all-button:hover { + background: #e0e7ff; +} + +.page-decide .apply-range-button { + background: #6366f1; + color: #fff; + border-color: #6366f1; +} + +.page-decide .apply-range-button:hover { + background: #4f46e5; + border-color: #4f46e5; +} + +.page-decide .clear-all-button { + background: #fff; + color: #ef4444; + border-color: #fecaca; +} + +.page-decide .clear-all-button:hover { + background: #fee2e2; +} + +.pdf-preview-header .preview-toggle-button { + background: #111827; +} + +.pdf-preview-header .preview-toggle-button:hover { + background: #374151; +} + +.page-decide .preview-toggle-button { + background: #fff; + color: #111827; + border-color: #e5e7eb; +} + +.page-decide .preview-toggle-button:hover { + background: #f3f4f6; +} + +.page-decide .preview-toggle-button.is-active { + background: #1f2937; + color: #fff; + border-color: #1f2937; +} + .pdf-preview-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - gap: 16px; - max-height: 400px; + gap: 12px; + max-height: 360px; overflow-y: auto; padding: 5px; } @@ -714,8 +1068,8 @@ } .pdf-page-item { - border: 2px solid #e5e7eb; - border-radius: 4px; + border: 1px solid #e5e7eb; + border-radius: 6px; cursor: pointer; transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s; position: relative; @@ -724,8 +1078,8 @@ } .pdf-page-item:not(.disabled):hover { - transform: scale(1.05); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + transform: scale(1.02); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.12); z-index: 10; } @@ -843,10 +1197,9 @@ } .options-panel { - padding: 16px; margin: 24px 0; } - .options-title { + .section-title { font-size: 16px; margin-bottom: 16px; font-weight: 600; @@ -917,6 +1270,18 @@ font-size: 11px; } + .hint-list li { + flex-direction: column; + align-items: flex-start; + gap: 2px; + } + + .hint-label, + .hint-value { + display: block; + width: 100%; + } + .action-buttons { flex-direction: column; gap: 12px; @@ -1009,6 +1374,10 @@ font-size: 0.9rem; } + .footer { + font-size: 0.85rem; + } + .page-decide { flex-direction: column; align-items: stretch; @@ -1016,14 +1385,69 @@ margin-top: 12px; } - .page-decide input[type="number"] { + .preview-toggle-button { + display: none; + } + + .page-input-row { + flex-direction: column; + align-items: flex-start; + gap: 6px; + flex-wrap: nowrap; + } + + .page-input-label { width: 100%; + margin-bottom: 2px; + } + + .page-input-controls { + width: 100%; + justify-content: center; + } + + .page-decide input[type="number"] { + width: 60px; + max-width: none; font-size: 0.9rem; padding: 6px 8px; } - .page-decide span { - display: none; + .page-decide .apply-range-button { + width: auto; + min-width: 44px; + height: 32px; + padding: 0 10px; + line-height: 1; + white-space: nowrap; + } + + .page-range-header { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .page-range-panel { + padding: 12px; + } + + .page-range-separator { + display: inline-flex; + align-items: center; + padding: 0 2px; + font-size: 0.9rem; + } + + .preview-actions { + width: 100%; + flex-direction: column; + align-items: stretch; + gap: 10px; + } + + .preview-actions button { + width: 100%; } .page-decide select { @@ -1046,4 +1470,35 @@ max-height: 240px; padding: 0.8rem; } + + .quiz-example-card { + padding: 14px; + } + + .quiz-example-question { + padding: 12px 14px; + } + + .quiz-example-option { + padding: 10px 12px; + } + + .quiz-example-option-index { + width: 24px; + height: 24px; + font-size: 0.85rem; + margin-right: 10px; + } + + .quiz-example-title, + .quiz-example-question-text, + .quiz-example-option-text { + font-size: 0.9rem; + } + + .quiz-example-question-text, + .quiz-example-option-text { + overflow-wrap: anywhere; + word-break: break-word; + } } diff --git a/src/pages/make-quiz/index.jsx b/src/pages/make-quiz/index.jsx new file mode 100644 index 0000000..b5690f5 --- /dev/null +++ b/src/pages/make-quiz/index.jsx @@ -0,0 +1,557 @@ +import { useTranslation } from "i18nexus"; +import Header from "#widgets/header"; +import Help from "#widgets/help"; +import { + useMakeQuiz, + levelDescriptions, + MAX_FILE_SIZE, + MAX_SELECT_PAGES, + SUPPORTED_EXTENSIONS, +} from "#features/make-quiz"; +import { Document, Page } from "react-pdf"; +import "react-pdf/dist/Page/AnnotationLayer.css"; +import "react-pdf/dist/Page/TextLayer.css"; +import { Link, useNavigate } from "react-router-dom"; +import "./index.css"; +import RecentChanges from "#widgets/recent-changes"; + +const MakeQuiz = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const acceptExtensions = SUPPORTED_EXTENSIONS.map((ext) => `.${ext}`).join( + ", " + ); + const { + state: { + file, + uploadedUrl, + isDragging, + questionType, + questionCount, + isProcessing, + version, + isSidebarOpen, + problemSetId, + quizLevel, + numPages, + selectedPages, + hoveredPage, + visiblePageCount, + pageRangeStart, + pageRangeEnd, + isPreviewVisible, + pdfPreviewRef, + showWaitMessage, + uploadElapsedTime, + generationElapsedTime, + fileExtension, + showHelp, + pdfOptions, + }, + actions: { + toggleSidebar, + setIsSidebarOpen, + setShowHelp, + handleDragOver, + handleDragEnter, + handleDragLeave, + handleDrop, + handleFileInput, + handleRemoveFile, + handleReCreate, + handleNavigateToQuiz, + onDocumentLoadSuccess, + handlePageSelection, + handleSelectAllPages, + handleClearAllPages, + handleApplyPageRange, + setPageRangeStart, + setPageRangeEnd, + setIsPreviewVisible, + handlePageMouseEnter, + handlePageMouseLeave, + generateQuestions, + handleQuestionTypeChange, + handleQuestionCountChange, + }, + } = useMakeQuiz({ t, navigate }); + + return ( +
+
+ +
+
+ {/* 파일 업로드 중일 때 */} + {isProcessing && !uploadedUrl ? ( +
+
+
+
+ {t("파일 업로드 중...")} + {Math.floor(uploadElapsedTime / 1000)} + {t("초")} +
+
+ {fileExtension && fileExtension !== "pdf" && ( +
+
+ {fileExtension.toUpperCase()} + {t("파일을 PDF로 변환하고 있어요")} +
+ + {t("파일 크기에 따라 시간이 소요될 수 있습니다")} + +
+
+ )} +
+ ) : !uploadedUrl ? ( + <> +
☁️
+
+ {t("파일을 여기에 드래그하세요")} +
+

{t("또는")}

+
+ {t("파일 선택하기")} + + +
+ + ) : ( + <> +
📄
+
+
{file.name}
+ {file.size && ( + + {(file.size / 1024 / 1024).toFixed(2)} MB + + )} +
+ + + )} + {!uploadedUrl && ( + <> +
+
    +

    {" "} +
  • + {t("크기 제한")} + + 📦 {MAX_FILE_SIZE / 1024 / 1024}MB + +
  • +
  • + {t("지원하는 파일")} + + ✅ {SUPPORTED_EXTENSIONS.join(", ")} + +
  • +
+
+
+

{" "} + {t("파일은 상업적 목적, AI 학습 목적으로 사용되지 않습니다.")} +

{" "} + {t("24시간 후 자동 삭제되며 별도로 저장, 공유되지 않습니다.")} +
+ + )} +
+ {/* Options Panel */} + {uploadedUrl && !problemSetId && ( +
+ <> +
+ {/* 문제 유형 세그먼티드 */} +
+ {t("1. 퀴즈 타입을 선택하세요!")} +
+
+ {[ + { key: "MULTIPLE", label: t("객관식") }, + { key: "BLANK", label: t("빈칸 넣기") }, + { key: "OX", label: t("OX 퀴즈") }, + ].map((type) => { + return ( + + ); + })} +
+
+ {/* ② 선택한 난이도에 해당하는 설명을 옆에 출력 */} +
+
+
+ {levelDescriptions[quizLevel]?.title} +
+
+

+ {levelDescriptions[quizLevel]?.question} +

+
+ {levelDescriptions[quizLevel]?.options?.length > 0 && ( +
+ {levelDescriptions[quizLevel].options.map( + (option, index) => ( +
+ + {index + 1} + + + {option} + +
+ ) + )} +
+ )} +
+
+
+
+ {/* 문제 개수 슬라이더 */} +
+
+ {t("2. 문제 개수를 지정하세요!")} +
+
+ + { + const newCount = +e.target.value; + handleQuestionCountChange(newCount); + }} + /> +
+
+ +
+
+
+
+ {t("3. 특정 페이지를 지정하세요!")} +
+
+ + {t("최대 ")} + {MAX_SELECT_PAGES} + {t(" 페이지")} + + + {t("선택할 수 있어요")} + +
+
+
+
+ + {t("원하는 페이지 입력:")} + +
+ setPageRangeStart(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleApplyPageRange(); + } + }} + disabled={!numPages} + /> + ~ + setPageRangeEnd(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleApplyPageRange(); + } + }} + disabled={!numPages} + /> + +
+
+
+ + + +
+
+
+ {uploadedUrl && ( +
+
+ {t("선택된 페이지 수: ")} + + {selectedPages.length}/{numPages ?? 0} + +
+ +
+
+ {Array.from( + new Array(Math.min(visiblePageCount, numPages)), + (el, index) => { + const pageNumber = index + 1; + const isDisabled = false; + + return ( +
{ + if (!isDisabled) { + handlePageSelection(pageNumber); + } + }} + onMouseEnter={(e) => { + handlePageMouseEnter(e, pageNumber); + }} + > + + +

+ {t("페이지")} + {pageNumber} +

+
+ ); + } + )} + {visiblePageCount < numPages && ( +
+
+

+ {t("더 많은 페이지 로딩 중... (")} + {visiblePageCount}/{numPages}) +

+
+ )} +
+ + {isPreviewVisible && hoveredPage && ( +
+ +
+ )} +
+ +
+ )} +
+ + {/* ④ 문서 미리보기 */} +
+
{t("4. 문제를 생성하세요!")}
+
+ {isProcessing ? ( +
+
+

+ {t("문제 생성 중...")} + {Math.floor(generationElapsedTime / 1000)} + {t("초")} +

{" "} + {t( + "생성된 문제의 개수는 간혹 지정한 개수와 맞지 않을 수 있습니다." + )} +

+ {showWaitMessage && ( +

+ {t("현재 생성중입니다 조금만 더 기다려주세요!")} +

+ )} +
+ ) : ( +

+ {t( + "문서를 분석하고 문제를 생성하려면 아래 버튼을 클릭하세요." + )} +

+ )} +
+
+ + {!isProcessing && !selectedPages.length && ( +

+ {t("페이지 정보를 불러오는 중입니다. 잠시만 기다려주세요.")} +

+ )} +
+
+
+ )} + {uploadedUrl && problemSetId && ( +
+
{t("생성된 문제")}
+
+
📝
+
+
+ {file.name} + {version > 0 && `.ver${version}`} +
+
+
+ + + +
+
+
+ )} + + {showHelp && } +
+ + {/* Footer */} +
+ © 2025 Q-Asker{" | "} + + {t("개인정보 처리방침")} + +

+ {t("문의 및 피드백")} + : + + {t("구글 폼 링크")} + + , + + inhapj01@gmail.com + +
+
+ ); +}; + +export default MakeQuiz; diff --git a/src/pages/privacy-policy/index.css b/src/pages/privacy-policy/index.css new file mode 100644 index 0000000..f913a6d --- /dev/null +++ b/src/pages/privacy-policy/index.css @@ -0,0 +1,91 @@ +.policy-page { + min-height: 100vh; + background: #f9fafb; + padding: 32px 16px 48px; +} + +.policy-container { + max-width: 820px; + margin: 0 auto; + background: #ffffff; + border-radius: 12px; + padding: 32px; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08); + color: #111827; + line-height: 1.7; +} + +.policy-actions { + display: flex; + justify-content: flex-start; + margin-bottom: 16px; +} + +.policy-back-link { + color: #2563eb; + text-decoration: none; + font-weight: 600; +} + +.policy-back-link:hover { + text-decoration: underline; +} + +.policy-header h1 { + font-size: 28px; + margin: 0 0 8px; +} + +.policy-effective-date { + color: #6b7280; + font-size: 0.95rem; + margin: 0 0 12px; +} + +.policy-intro { + margin: 0 0 24px; + color: #374151; +} + +.policy-section { + margin-bottom: 24px; +} + +.policy-section h2 { + font-size: 18px; + margin: 0 0 12px; + color: #111827; +} + +.policy-section ul { + margin: 0; + padding-left: 20px; + color: #374151; +} + +.policy-section li { + margin-bottom: 8px; +} + +.policy-notice { + margin-top: 24px; + color: #6b7280; +} + +@media (max-width: 768px) { + .policy-page { + padding: 24px 12px 36px; + } + + .policy-container { + padding: 24px; + } + + .policy-header h1 { + font-size: 22px; + } + + .policy-section h2 { + font-size: 16px; + } +} diff --git a/src/pages/privacy-policy/index.jsx b/src/pages/privacy-policy/index.jsx new file mode 100644 index 0000000..9101ddf --- /dev/null +++ b/src/pages/privacy-policy/index.jsx @@ -0,0 +1,102 @@ +import React from "react"; +import { useTranslation } from "i18nexus"; +import { Link } from "react-router-dom"; +import "./index.css"; + +const PrivacyPolicy = () => { + const { t } = useTranslation(); + + return ( +
+
+
+ + ← {t("홈으로")} + +
+
+

{t("개인정보 처리방침")}

+

{t("시행일: 2026-01-30")}

+

+ {t( + 'Q-Asker(이하 "서비스")는 이용자의 개인정보를 소중히 보호하며 관련 법령을 준수합니다.', + )} +

+
+ +
+

{t("1. 수집하는 개인정보")}

+
    +
  • {t("문의 시: 이메일 주소, 문의 내용")}
  • +
  • + {t("서비스 이용 시 자동 수집: IP 주소, 브라우저 정보, 접속 로그")} +
  • +
  • + {t( + "업로드한 파일: 퀴즈 생성 목적의 처리 과정에서 일시적으로 저장", + )} +
  • +
+
+ +
+

{t("2. 개인정보의 이용 목적")}

+
    +
  • {t("문의 및 고객지원 대응")}
  • +
  • {t("서비스 제공 및 기능 개선")}
  • +
  • {t("보안 및 부정 이용 방지")}
  • +
+
+ +
+

{t("3. 보관 및 이용 기간")}

+
    +
  • {t("업로드한 파일은 처리 후 24시간 이내 자동 삭제됩니다.")}
  • +
  • {t("그 외 정보는 목적 달성 시 지체 없이 파기합니다.")}
  • +
+
+ +
+

{t("4. 제3자 제공")}

+
    +
  • {t("원칙적으로 제3자에게 제공하지 않습니다.")}
  • +
  • {t("다만, 법령에 따라 요청되는 경우 제공될 수 있습니다.")}
  • +
+
+ +
+

{t("5. 개인정보 처리 위탁")}

+
    +
  • + {t( + "서비스 운영에 필요한 범위 내에서 일부 업무를 위탁할 수 있습니다.", + )} +
  • +
  • {t("위탁 시 관련 법령에 따라 관리·감독합니다.")}
  • +
+
+ +
+

{t("6. 이용자 권리")}

+
    +
  • {t("개인정보 열람, 정정, 삭제를 요청할 수 있습니다.")}
  • +
  • {t("문의는 아래 연락처로 접수됩니다.")}
  • +
+
+ +
+

{t("7. 문의처")}

+

{t("이메일: inhapj01@gmail.com")}

+
+ +

+ {t( + "본 방침은 서비스 개선에 따라 변경될 수 있으며, 변경 시 공지합니다.", + )} +

+
+
+ ); +}; + +export default PrivacyPolicy; diff --git a/src/pages/QuizExplanation.css b/src/pages/quiz-explanation/index.css similarity index 99% rename from src/pages/QuizExplanation.css rename to src/pages/quiz-explanation/index.css index fb21839..bde2532 100644 --- a/src/pages/QuizExplanation.css +++ b/src/pages/quiz-explanation/index.css @@ -211,6 +211,8 @@ body { .question-text { margin: 0; font-size: 1rem; + white-space: pre-wrap; + word-break: break-word; } /* ─── "선지" 스타일링 ─── */ @@ -262,6 +264,8 @@ body { font-size: 1rem; line-height: 1.8; padding-right: 0.75rem; + white-space: pre-wrap; + word-break: break-word; } /* 확인 버튼 */ @@ -341,6 +345,8 @@ body { font-size: 1rem; line-height: 1.8; color: #333; + white-space: pre-wrap; + word-break: break-word; } .specific-explanation-section { diff --git a/src/pages/QuizExplanation.jsx b/src/pages/quiz-explanation/index.jsx similarity index 56% rename from src/pages/QuizExplanation.jsx rename to src/pages/quiz-explanation/index.jsx index e80a854..9256c59 100644 --- a/src/pages/QuizExplanation.jsx +++ b/src/pages/quiz-explanation/index.jsx @@ -1,132 +1,60 @@ import { useTranslation } from "i18nexus"; -import axiosInstance from "#shared/api"; -import CustomToast from "#shared/toast"; -import { trackQuizEvents } from "#utils/analytics"; -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { Document, Page, pdfjs } from "react-pdf"; +import React from "react"; +import { Document, Page } from "react-pdf"; import { useLocation, useNavigate, useParams } from "react-router-dom"; -import "./QuizExplanation.css"; - -pdfjs.GlobalWorkerOptions.workerSrc = new URL( - "pdfjs-dist/build/pdf.worker.min.mjs", - import.meta.url -).toString(); +import { useQuizExplanation } from "#features/quiz-explanation"; +import "./index.css"; const QuizExplanation = () => { const { t } = useTranslation(); const { problemSetId } = useParams(); const navigate = useNavigate(); const { state } = useLocation(); - const [showPdf, setShowPdf] = useState(false); - const [pdfWidth, setPdfWidth] = useState(600); - const pdfContainerRef = useRef(null); - const [currentPdfPage, setCurrentPdfPage] = useState(0); - const [showWrongOnly, setShowWrongOnly] = useState(false); - const [specificExplanation, setSpecificExplanation] = useState(""); - const [isSpecificExplanationLoading, setIsSpecificExplanationLoading] = - useState(false); - - // PDF 옵션 메모이제이션 - const pdfOptions = useMemo( - () => ({ - cMapUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/cmaps/`, - cMapPacked: true, - standardFontDataUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/standard_fonts/`, - }), - [] - ); - - // state로 전달된 값 꺼내기 const { quizzes: initialQuizzes = [], explanation: rawExplanation = [], uploadedUrl, } = state || {}; - - const [currentQuestion, setCurrentQuestion] = useState(1); - const totalQuestions = initialQuizzes.length; - const allExplanation = Array.isArray(rawExplanation.results) - ? rawExplanation.results - : []; - - // 오답만 보기용 필터링된 퀴즈 목록 - const getFilteredQuizzes = () => { - if (!showWrongOnly) return initialQuizzes; - - return initialQuizzes.filter((q) => { - if (q.userAnswer === undefined || q.userAnswer === null) return false; - - const correctOption = q.selections.find((opt) => opt.correct === true); - if (!correctOption) return false; - - return Number(q.userAnswer) !== Number(correctOption.id); - }); - }; - - const filteredQuizzes = getFilteredQuizzes(); - const filteredTotalQuestions = filteredQuizzes.length; - - // 로딩 체크 - const [isLoading, setIsLoading] = useState(true); - - // 피드백 다이얼로그 없이 바로 이동하는 함수 - const handleExit = (targetPath = "/") => { - navigate(targetPath); - }; - - useEffect(() => { - if (!problemSetId || initialQuizzes.length === 0) { - CustomToast.error(t("유효한 퀴즈 정보가 없습니다. 홈으로 이동합니다.")); - navigate("/"); - } else { - setIsLoading(false); - trackQuizEvents.viewExplanation(problemSetId, currentQuestion); - } - }, [problemSetId, initialQuizzes, navigate, currentQuestion]); - - useEffect(() => { - const calculatePdfWidth = () => { - if (pdfContainerRef.current) { - const containerWidth = pdfContainerRef.current.offsetWidth; - const isMobile = window.innerWidth <= 768; - const padding = isMobile ? 20 : 40; - const maxWidth = isMobile - ? containerWidth - padding - : Math.min(containerWidth - padding, 1200); - setPdfWidth(maxWidth); - } - }; - - calculatePdfWidth(); - window.addEventListener("resize", calculatePdfWidth); - window.addEventListener("orientationchange", calculatePdfWidth); - - return () => { - window.removeEventListener("resize", calculatePdfWidth); - window.removeEventListener("orientationchange", calculatePdfWidth); - }; - }, [showPdf]); - - useEffect(() => { - setCurrentPdfPage(0); - setSpecificExplanation(""); - }, [currentQuestion]); - - // 오답만 보기 토글 시 현재 문제 유효성 체크 - useEffect(() => { - if (showWrongOnly) { - if (filteredTotalQuestions === 0) { - // 오답이 없는 경우 토글을 다시 끄고 알림 - setShowWrongOnly(false); - CustomToast.error(t("오답이 없습니다!")); - return; - } - - if (currentQuestion > filteredTotalQuestions) { - setCurrentQuestion(1); - } - } - }, [showWrongOnly, filteredTotalQuestions, currentQuestion]); + const { + state: { + showPdf, + pdfWidth, + pdfContainerRef, + currentPdfPage, + showWrongOnly, + specificExplanation, + isSpecificExplanationLoading, + currentQuestion, + totalQuestions, + filteredQuizzes, + filteredTotalQuestions, + isLoading, + currentQuiz, + thisExplanationText, + thisExplanationObj, + pdfOptions, + }, + actions: { + handleExit, + handlePrev, + handleNext, + handleFetchSpecificExplanation, + handleQuestionClick, + handlePdfToggle, + handleWrongOnlyToggle, + handlePrevPdfPage, + handleNextPdfPage, + setCurrentPdfPage, + renderTextWithLinks, + }, + } = useQuizExplanation({ + t, + navigate, + problemSetId, + initialQuizzes, + rawExplanation, + uploadedUrl, + }); if (isLoading) { return ( @@ -137,148 +65,6 @@ const QuizExplanation = () => { ); } - // 현재 문제 객체 - const currentQuizIndex = showWrongOnly - ? currentQuestion - 1 - : currentQuestion - 1; - - const currentQuiz = showWrongOnly - ? filteredQuizzes[currentQuestion - 1] || { selections: [], userAnswer: 0 } - : initialQuizzes[currentQuestion - 1] || { selections: [], userAnswer: 0 }; - - // 이 문제에 대응하는 해설을 찾되, "allExplanation"이 배열이므로 find 사용 가능 - const thisExplanationObj = - allExplanation.find((e) => e.number === currentQuiz.number) || {}; - const thisExplanationText = - thisExplanationObj.explanation || t("해설이 없습니다."); - - // 이전/다음 핸들러 - const handlePrev = () => { - if (currentQuestion > 1) { - const prevQuestion = currentQuestion - 1; - // 문제 네비게이션 추적 - trackQuizEvents.navigateQuestion( - problemSetId, - currentQuestion, - prevQuestion - ); - setCurrentQuestion(prevQuestion); - } - }; - const handleNext = () => { - const maxQuestions = showWrongOnly - ? filteredTotalQuestions - : totalQuestions; - if (currentQuestion < maxQuestions) { - const nextQuestion = currentQuestion + 1; - // 문제 네비게이션 추적 - trackQuizEvents.navigateQuestion( - problemSetId, - currentQuestion, - nextQuestion - ); - setCurrentQuestion(nextQuestion); - } - }; - - const handleFetchSpecificExplanation = async () => { - setIsSpecificExplanationLoading(true); - try { - const response = await axiosInstance.get( - `/specific-explanation/${problemSetId}?number=${currentQuiz.number}` - ); - setSpecificExplanation(response.data.specificExplanation); - } catch (error) { - console.error(t("상세 해설을 불러오는데 실패했습니다."), error); - // 임시: 에러 발생 시 모의 상세 해설을 표시합니다. - CustomToast.error(t("상세 해설을 불러오는데 실패했습니다.")); - } finally { - setIsSpecificExplanationLoading(false); - } - }; - - // 문제 번호 직접 클릭 핸들러 - const handleQuestionClick = (questionNumber) => { - if (questionNumber !== currentQuestion) { - // 문제 네비게이션 추적 - trackQuizEvents.navigateQuestion( - problemSetId, - currentQuestion, - questionNumber - ); - setCurrentQuestion(questionNumber); - } - }; - - // PDF 토글 핸들러 - const handlePdfToggle = () => { - const newShowPdf = !showPdf; - setShowPdf(newShowPdf); - // PDF 슬라이드 토글 추적 - trackQuizEvents.togglePdfSlide(problemSetId, newShowPdf); - }; - - // 오답만 보기 토글 핸들러 - const handleWrongOnlyToggle = () => { - const newShowWrongOnly = !showWrongOnly; - setShowWrongOnly(newShowWrongOnly); - - // 토글 시 첫 번째 문제로 이동 - setCurrentQuestion(1); - }; - - // PDF 페이지 네비게이션 핸들러 - const handlePrevPdfPage = () => { - if (currentPdfPage > 0) { - setCurrentPdfPage(currentPdfPage - 1); - } - }; - - const handleNextPdfPage = () => { - const currentQuiz = showWrongOnly - ? filteredQuizzes[currentQuestion - 1] || { - selections: [], - userAnswer: 0, - } - : initialQuizzes[currentQuestion - 1] || { - selections: [], - userAnswer: 0, - }; - const currentExplanation = - allExplanation.find((e) => e.number === currentQuiz.number) || {}; - const currentPages = currentExplanation?.referencedPages || []; - if (currentPdfPage < currentPages.length - 1) { - setCurrentPdfPage(currentPdfPage + 1); - } - }; - - // URL을 링크로 변환하는 함수 - const renderTextWithLinks = (text) => { - if (!text) return text; - - // URL 패턴을 찾는 정규식 (http:// 또는 https://로 시작하는 URL) - const urlRegex = /(https?:\/\/[^\s)]+)/g; - - const parts = text.split(urlRegex); - - return parts.map((part, index) => { - if (urlRegex.test(part)) { - return ( - - {part} - - ); - } - return part; - }); - }; - return (
diff --git a/src/pages/QuizHistory.css b/src/pages/quiz-history/index.css similarity index 100% rename from src/pages/QuizHistory.css rename to src/pages/quiz-history/index.css diff --git a/src/pages/quiz-history/index.jsx b/src/pages/quiz-history/index.jsx new file mode 100644 index 0000000..bf19a41 --- /dev/null +++ b/src/pages/quiz-history/index.jsx @@ -0,0 +1,247 @@ +import { useTranslation } from "i18nexus"; +import Header from "#widgets/header"; +import React from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuizHistory } from "#features/quiz-history"; +import "./index.css"; + +const QuizHistory = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { + state: { quizHistory, loading, explanationLoading, isSidebarOpen, stats }, + actions: { + toggleSidebar, + setIsSidebarOpen, + navigateToExplanation, + navigateToQuiz, + deleteQuizRecord, + clearAllHistory, + formatDate, + handleCreateFromEmpty, + }, + } = useQuizHistory({ t, navigate }); + + if (loading) { + return ( + <> +
+ +
+
+
+

{t("기록을 불러오는 중...")}

+
+
+ + ); + } + + return ( + <> +
+ +
+
+
+
+

{t("내 퀴즈 기록")}

+

{t("지금까지 만들고 푼 퀴즈들을 확인해보세요")}

+
+ + {quizHistory.length > 0 && ( +
+ +
+ )} +
+ + {/* 통계 섹션 */} + {quizHistory.length > 0 && ( +
+
+
+
📝
+
+
{stats.totalQuizzes}
+
{t("총 퀴즈 수")}
+
+
+ +
+
+
+
{stats.completedQuizzes}
+
{t("완료한 퀴즈")}
+
+
+ +
+
📊
+
+
{stats.completionRate}%
+
{t("완료율")}
+
+
+ +
+
🏆
+
+
+ {stats.averageScore} + {t("점")} +
+
{t("평균 점수")}
+
+
+
+
+ )} + + {/* 퀴즈 보관 안내 */} + {quizHistory.length > 0 && ( +
+
+ 📋 +

{t("퀴즈 보관 정책")}

+
+
+ {t("• 퀴즈 기록은 최대")} + {t("20개")} + {t("까지 자동으로 저장됩니다")} +
+ {t("• 생성된 퀴즈는")}{" "} + {t("24시간 후 서버에서 자동 삭제")} + {t("되어 해설을 볼 수\n 없게 됩니다")} +
+ {t( + "• 중요한 퀴즈는 생성 후 24시간 내에 완료하여 기록을\n 남겨두시기 바랍니다" + )} +
+
+ )} + + {/* 기록 목록 */} +
+ {quizHistory.length === 0 ? ( +
+
📋
+

{t("아직 만든 퀴즈가 없습니다")}

+

{t("퀴즈를 만들어서 문제를 풀어보세요!")}

+ +
+ ) : ( +
+ {quizHistory.map((record) => ( +
+
+
+ 📄 + + {record.fileName} + + + {record.status === "completed" + ? t("완료") + : t("미완료")} + +
+ +
+ + 📝 {record.questionCount} + {t("문제")} + + + 🎯 {record.quizLevel} + + {record.status === "completed" && ( + <> + + 🏆 {record.score} + {t("점 (")} + {record.correctCount}/{record.totalQuestions}) + + + ⏱️ {record.totalTime} + + + )} +
+ +
+
+ {t("생성:")} + {formatDate(record.createdAt)} +
+ {record.completedAt && ( +
+ {t("완료:")} + {formatDate(record.completedAt)} +
+ )} +
+
+ +
+ {record.status === "completed" ? ( + <> + + + + ) : ( + + )} + +
+
+ ))} +
+ )} +
+
+
+ + ); +}; + +export default QuizHistory; diff --git a/src/pages/QuizResult.css b/src/pages/quiz-result/index.css similarity index 97% rename from src/pages/QuizResult.css rename to src/pages/quiz-result/index.css index 577967e..e19a152 100644 --- a/src/pages/QuizResult.css +++ b/src/pages/quiz-result/index.css @@ -107,6 +107,8 @@ font-weight: 600; margin-bottom: 0.75rem; color: #222; + white-space: pre-wrap; + word-break: break-word; } .result-user-answer { @@ -115,6 +117,8 @@ position: relative; color: #555; margin-bottom: 0.5rem; + white-space: pre-wrap; + word-break: break-word; } .result-user-answer::before { @@ -134,6 +138,8 @@ border-radius: 0.5rem; color: #2d6a4f; border: 1px solid #cce6db; + white-space: pre-wrap; + word-break: break-word; } .result-status { diff --git a/src/pages/quiz-result/index.jsx b/src/pages/quiz-result/index.jsx new file mode 100644 index 0000000..46003c4 --- /dev/null +++ b/src/pages/quiz-result/index.jsx @@ -0,0 +1,114 @@ +import { useTranslation } from "i18nexus"; +import React from "react"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; +import { useQuizResult } from "#features/quiz-result"; +import "./index.css"; + +const QuizResult = () => { + const { t } = useTranslation(); + const { state } = useLocation(); + const navigate = useNavigate(); + const { problemSetId } = useParams(); + const { quizzes = [], totalTime = "00:00:00", uploadedUrl } = state || {}; + const { + state: { correctCount, scorePercent }, + actions: { getQuizExplanation }, + } = useQuizResult({ + t, + navigate, + problemSetId, + quizzes, + totalTime, + uploadedUrl, + }); + + return ( +
+
+ {/* 문제 수 아이템 */} +
+ 📋 +
+ {t("문제 수")} + + {quizzes.length} + {t("개")} + +
+
+ + {/* 걸린 시간 아이템 */} +
+ ⏱️ +
+ {t("걸린 시간")} + {totalTime} +
+
+ + {/* 점수 아이템 */} +
+ 🏆 +
+ {t("점수")} + + {scorePercent} + {t("점")} + +
+
+
+ +
+
+ {quizzes.map((q) => { + const userAns = q.userAnswer; + const selection = q.selections.find((s) => s.id === userAns) || {}; + const isCorrect = selection.correct === true; + const correctSelection = + q.selections.find((s) => s.correct === true) || {}; + + return ( +
+
+ {q.number}. {q.title} +
+ +
+ {t("선택한 답:")} + {userAns === 0 ? t("입력 X") : selection.content} +
+ + {!isCorrect && ( +
+ {t("정답 답안:")} + {correctSelection.content} +
+ )} + +
+ {isCorrect ? t("정답") : t("오답")} +
+
+ ); + })} +
+
+ + +
+ ); +}; + +export default QuizResult; diff --git a/src/pages/SolveQuiz.css b/src/pages/solve-quiz/index.css similarity index 95% rename from src/pages/SolveQuiz.css rename to src/pages/solve-quiz/index.css index a14bb94..9f458d3 100644 --- a/src/pages/SolveQuiz.css +++ b/src/pages/solve-quiz/index.css @@ -114,6 +114,31 @@ body { transform: none; } +.solve-skipped-button.solve-pending { + background-color: #eef2ff; + color: #5b4ed6; + border-style: dashed; + cursor: default; + animation: pendingPulse 1.2s ease-in-out infinite; +} + +.solve-skipped-button.solve-pending:hover { + background-color: #eef2ff; + transform: none; +} + +@keyframes pendingPulse { + 0% { + opacity: 0.6; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.6; + } +} + /* 중앙 패널: 퀴즈 풀이 영역 */ .solve-center-panel { max-width: 900px; @@ -156,6 +181,8 @@ body { margin: 0; font-size: 1rem; line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; } .solve-review-area { @@ -231,6 +258,8 @@ body { font-size: 1rem; line-height: 1.8; padding-right: 0.75rem; + white-space: pre-wrap; + word-break: break-word; } /* 제출 버튼 */ @@ -554,6 +583,8 @@ body { display: flex; align-items: center; gap: 8px; + white-space: pre-wrap; + word-break: break-word; } .answer-text.unanswered { diff --git a/src/pages/SolveQuiz.jsx b/src/pages/solve-quiz/index.jsx similarity index 58% rename from src/pages/SolveQuiz.jsx rename to src/pages/solve-quiz/index.jsx index 39eb667..1571f6d 100644 --- a/src/pages/SolveQuiz.jsx +++ b/src/pages/solve-quiz/index.jsx @@ -1,11 +1,10 @@ import { useTranslation } from "i18nexus"; // SolveQuiz.jsx -import axiosInstance from "#shared/api"; -import CustomToast from "#shared/toast"; -import { trackQuizEvents } from "#utils/analytics"; -import React, { useCallback, useEffect, useState } from "react"; +import React from "react"; import { useLocation, useNavigate, useParams } from "react-router-dom"; -import "./SolveQuiz.css"; +import { useSolveQuiz } from "#features/solve-quiz"; +import { useQuizGenerationStore } from "#features/quiz-generation"; +import "./index.css"; const SolveQuiz = () => { const { t } = useTranslation(); @@ -13,212 +12,57 @@ const SolveQuiz = () => { const navigate = useNavigate(); const location = useLocation(); const { uploadedUrl } = location.state || {}; - - // States - const [quizzes, setQuizzes] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [currentTime, setCurrentTime] = useState("00:00:00"); - const [selectedOption, setSelectedOption] = useState(null); - const [currentQuestion, setCurrentQuestion] = useState(1); - const [showSubmitDialog, setShowSubmitDialog] = useState(false); - const totalQuestions = quizzes.length; - - // Redirect if no problemSetId - useEffect(() => { - if (!problemSetId) { - navigate("/"); - } - }, [problemSetId, navigate]); - - // Fetch quiz data on mount - useEffect(() => { - const fetchQuiz = async () => { - try { - const res = await axiosInstance.get(`/problem-set/${problemSetId}`); - const data = res.data; - console.log(data); - setQuizzes(data.quiz || []); - - // 퀴즈 시작 추적 - trackQuizEvents.startQuiz(problemSetId); - } catch (err) { - navigate("/"); - } finally { - setIsLoading(false); - } - }; - - if (problemSetId) { - fetchQuiz(); - } else { - setIsLoading(false); - } - }, [problemSetId, navigate]); - - // Timer effect - useEffect(() => { - let seconds = 0, - minutes = 0, - hours = 0; - const timer = setInterval(() => { - seconds++; - if (seconds === 60) { - seconds = 0; - minutes++; - } - if (minutes === 60) { - minutes = 0; - hours++; - } - setCurrentTime( - `${String(hours).padStart(2, "0")}:` + - `${String(minutes).padStart(2, "0")}:` + - `${String(seconds).padStart(2, "0")}` - ); - }, 1000); - return () => clearInterval(timer); - }, []); - - // Sync selected option when question changes - useEffect(() => { - const saved = quizzes[currentQuestion - 1]?.userAnswer; - setSelectedOption(saved && saved !== 0 ? saved : null); - }, [currentQuestion, quizzes]); - - // Handlers - const handleOptionSelect = (id) => { - const currentQuiz = quizzes[currentQuestion - 1]; - const selectedOption = currentQuiz?.selections?.find((s) => s.id === id); - - // 답안 선택 추적 - if (selectedOption) { - trackQuizEvents.selectAnswer( - problemSetId, - currentQuestion, - id, - selectedOption.correct || false - ); - } - - setQuizzes((prev) => - prev.map((q, idx) => - idx === currentQuestion - 1 ? { ...q, userAnswer: id } : q - ) - ); - setSelectedOption(id); - }; - - const handlePrev = () => { - if (currentQuestion > 1) { - const prevQuestion = currentQuestion - 1; - trackQuizEvents.navigateQuestion( - problemSetId, - currentQuestion, - prevQuestion - ); - setCurrentQuestion(prevQuestion); - } - }; - - const handleNext = () => { - if (currentQuestion < totalQuestions) { - const nextQuestion = currentQuestion + 1; - trackQuizEvents.navigateQuestion( - problemSetId, - currentQuestion, - nextQuestion - ); - setCurrentQuestion(nextQuestion); - } - }; - - const handleSubmit = () => { - // 문제 확인 버튼 클릭 추적 - trackQuizEvents.confirmAnswer(problemSetId, currentQuestion); - - if (currentQuestion === totalQuestions) { - CustomToast.info(t("마지막 문제입니다.")); - return; - } - - const nextQuestion = currentQuestion + 1; - trackQuizEvents.navigateQuestion( - problemSetId, + const storeProblemSetId = useQuizGenerationStore( + (state) => state.problemSetId, + ); + const streamQuizzes = useQuizGenerationStore((state) => state.quizzes); + const streamIsLoading = useQuizGenerationStore((state) => state.isLoading); + const streamTotalCount = useQuizGenerationStore((state) => state.totalCount); + + const streamedQuizzes = + storeProblemSetId === problemSetId ? streamQuizzes : []; + const isStreaming = + storeProblemSetId === problemSetId ? streamIsLoading : false; + const totalCount = storeProblemSetId === problemSetId ? streamTotalCount : 0; + const { + state: { + quizzes, + isLoading, + currentTime, + selectedOption, currentQuestion, - nextQuestion - ); - setCurrentQuestion(nextQuestion); - }; - - const handleCheckToggle = () => { - const currentQuiz = quizzes[currentQuestion - 1]; - const newCheckState = !currentQuiz.check; - - // 검토 체크박스 토글 추적 - trackQuizEvents.toggleReview(problemSetId, currentQuestion, newCheckState); - - setQuizzes((prev) => - prev.map((q, idx) => - idx === currentQuestion - 1 ? { ...q, check: newCheckState } : q - ) - ); - }; - - const handleFinish = () => { - setShowSubmitDialog(true); - }; - - const handleConfirmSubmit = useCallback(() => { - const unansweredCount = quizzes.filter((q) => q.userAnswer === 0).length; - const reviewCount = quizzes.filter((q) => q.check).length; - const answeredCount = quizzes.length - unansweredCount; - - // 퀴즈 제출 추적 - trackQuizEvents.submitQuiz( - problemSetId, + showSubmitDialog, + totalQuestions, + unansweredCount, + reviewCount, answeredCount, - quizzes.length, - reviewCount - ); - - navigate(`/result/${problemSetId}`, { - state: { quizzes, totalTime: currentTime, uploadedUrl }, - }); - }, [quizzes, problemSetId, currentTime, uploadedUrl, navigate]); - - const handleCancelSubmit = useCallback(() => { - setShowSubmitDialog(false); - }, []); - - const handleJumpTo = (num) => { - if (num !== currentQuestion) { - // 문제 네비게이션 추적 - trackQuizEvents.navigateQuestion(problemSetId, currentQuestion, num); - } - setCurrentQuestion(num); - }; - - // 제출 다이얼로그용 통계 계산 - const unansweredCount = quizzes.filter((q) => q.userAnswer === 0).length; - const reviewCount = quizzes.filter((q) => q.check).length; - const answeredCount = quizzes.length - unansweredCount; - - const handleOverlayClick = useCallback((e) => { - if (e.target === e.currentTarget) { - setShowSubmitDialog(false); - } - }, []); - - if (isLoading) { - return ( -
-
-

{t("문제 로딩 중…")}

-
- ); - } - - const currentQuiz = quizzes[currentQuestion - 1] || {}; + currentQuiz, + }, + actions: { + handleOptionSelect, + handlePrev, + handleNext, + handleSubmit, + handleCheckToggle, + handleFinish, + handleConfirmSubmit, + handleCancelSubmit, + handleJumpTo, + handleOverlayClick, + }, + } = useSolveQuiz({ + t, + navigate, + problemSetId, + uploadedUrl, + streamedQuizzes, + isStreaming, + }); + + const remainingCount = + isStreaming && totalCount > 0 + ? Math.max(0, totalCount - totalQuestions) + : 0; return (
@@ -278,7 +122,7 @@ const SolveQuiz = () => { quiz.userAnswer === 0 ? t("미선택") : quiz.selections?.find( - (sel) => sel.id === quiz.userAnswer + (sel) => sel.id === quiz.userAnswer, )?.content || `${quiz.userAnswer}번`; return ( @@ -344,7 +188,6 @@ const SolveQuiz = () => { {t("다음")} - {/* ─── 여기부터 문제 영역 ─── */} {isLoading ? (
@@ -367,6 +210,15 @@ const SolveQuiz = () => { {q.number} ))} + {Array.from({ length: remainingCount }).map((_, index) => ( + + ))}
@@ -425,6 +277,15 @@ const SolveQuiz = () => { {q.number} ))} + {Array.from({ length: remainingCount }).map((_, index) => ( + + ))}
diff --git a/src/shared/api/index.js b/src/shared/api/index.js index b025bde..9addc48 100644 --- a/src/shared/api/index.js +++ b/src/shared/api/index.js @@ -2,8 +2,21 @@ import CustomToast from "#shared/toast"; import axios from "axios"; const apiBaseURL = import.meta.env.VITE_BASE_URL; +let getAccessToken = () => null; +let clearAuth = () => {}; + +export const configureAuth = ({ getAccessToken: getToken, clearAuth: clear }) => { + if (typeof getToken === "function") { + getAccessToken = getToken; + } + if (typeof clear === "function") { + clearAuth = clear; + } +}; + const axiosInstance = axios.create({ baseURL: apiBaseURL, + withCredentials: true, headers: { "Content-Type": "application/json", }, @@ -11,6 +24,13 @@ const axiosInstance = axios.create({ axiosInstance.interceptors.request.use( (config) => { + const accessToken = getAccessToken(); + if (accessToken) { + config.headers = config.headers ?? {}; + if (!config.headers.Authorization) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + } if (config.isMultipart) { delete config.headers["Content-Type"]; } @@ -18,29 +38,38 @@ axiosInstance.interceptors.request.use( }, (error) => { CustomToast.error(error.message); - console.log(error.code, error.message); return Promise.reject(error); } ); axiosInstance.interceptors.response.use( (response) => response, - (error) => { - CustomToast.error(error.response.data.message); - console.log("Axios Error 전체 ▶", error); - // 에러를 JSON으로도 찍어볼 수 있습니다. (순환 참조가 있으면 주의) - try { - console.log("Axios Error.toJSON() ▶", error.toJSON()); - } catch (e) { - console.warn("error.toJSON() 출력 중 예외 발생:", e); + async (error) => { + let errorToHandle = error; + const status = error?.response?.status; + const { skipAuthRefresh, skipErrorToast } = error?.config || {}; + + if (status === 401) { + if (skipAuthRefresh) { + return Promise.reject(errorToHandle); + } + clearAuth(); + window.location.assign("/login"); + CustomToast.error("로그인이 필요합니다."); + return Promise.reject(errorToHandle); } - // error.request나 error.config 같은 속성들도 찍어보세요. - console.log("▶ request 객체 ▶", error.request); - console.log("▶ config ▶", error.config); - console.log("▶ response ▶", error.response); + if (!skipErrorToast) { + const message = + errorToHandle?.response?.data?.message || errorToHandle?.message; + if (message) { + CustomToast.error(message); + } else { + CustomToast.error("알 수 없는 오류가 발생했습니다."); + } + } - return Promise.reject(error); + return Promise.reject(errorToHandle); } ); diff --git a/src/shared/i18n/en.json b/src/shared/i18n/en.json new file mode 100644 index 0000000..b4a56c1 --- /dev/null +++ b/src/shared/i18n/en.json @@ -0,0 +1,274 @@ +{ + " 페이지": " page", + "\"문제 풀기\" 버튼으로": "Hit the \"Solve Quiz\" button", + "• 생성된 퀴즈는": "• Your generated quizzes are", + "• 중요한 퀴즈는 생성 후 24시간 내에 완료하여 기록을\n 남겨두시기 바랍니다": "• Try to finish important quizzes within 24 hours so we can keep the record\n of them", + "• 퀴즈 기록은 최대": "• Quiz history is kept up to", + "⏰ 자료 보호": "⏰ Data protection", + "✕ 파일 삭제": "✕ Delete file", + "❌ 오답만": "❌ Wrong answers only", + "🌟 Q-Asker를 신뢰할 수 있는 이유": "🌟 Why you can trust Q-Asker", + "💡 AI 퀴즈 활용 200% 팁": "💡 Tips to get the most out of AI quizzes", + "📄 관련 슬라이드": "📄 Related slides", + "📈 유형별 풀어보기": "📈 Practice by type", + "📋 명확한 문제 생성 기준": "📋 Clear question generation criteria", + "📚 참조 페이지": "📚 Reference pages", + "📝 AI 퀴즈 만들기 6단계 가이드": "📝 6-step guide to making AI quizzes", + "📞 문의 및 피드백": "📞 Contact & feedback", + "🔄 복습 퀴즈": "🔄 Review quiz", + "🙋‍♀️ 자주 묻는 질문 (FAQ)": "🙋‍♀️ Frequently Asked Questions (FAQ)", + "🚨 꼭 읽어주세요: 주의사항": "🚨 Read this first: Important notes", + "1. 빈칸 채우기로 핵심 개념을 정리하세요.": "1. Use fill-in-the-blank to recap key concepts.", + "1. 수집하는 개인정보": "1. Personal information we collect", + "1. 퀴즈 타입을 선택하세요!": "1. Pick a quiz type!", + "1단계: 파일 업로드": "Step 1: Upload a file", + "2. OX로 빠르게 개념을 점검하세요.": "2. Quickly check concepts with True/False.", + "2. 개인정보의 이용 목적": "2. Purpose of using personal information", + "2. 문제 개수를 지정하세요!": "2. Choose how many questions!", + "20개": "20", + "24시간 후 서버에서 자동 삭제": "Auto-deleted from the server after 24 hours", + "24시간 후 자동 삭제되며 별도로 저장, 공유되지 않습니다.": "Auto-deleted after 24 hours and not saved or shared elsewhere.", + "2단계: 퀴즈 옵션 설정": "Step 2: Set quiz options", + "3. 객관식으로 개념을 응용해 보세요.": "3. Try applying concepts with multiple choice.", + "3. 보관 및 이용 기간": "3. Retention and use period", + "3. 특정 페이지를 지정하세요!": "3. Pick specific pages!", + "3단계: AI 문제 생성": "Step 3: Generate AI questions", + "4. 문제를 생성하세요!": "4. Generate questions!", + "4. 제3자 제공": "4. Third-party provision", + "4단계: 퀴즈 풀기": "Step 4: Solve the quiz", + "5 ~ 25개 (5개 단위)": "5 to 25 (steps of 5)", + "5. 개인정보 처리 위탁": "5. Outsourced data processing", + "5단계: 결과 및 해설 확인": "Step 5: Check results and explanations", + "6. 이용자 권리": "6. User rights", + "6단계: 퀴즈 기록 관리": "Step 6: Manage quiz history", + "7. 문의처": "7. Contact information", + "AI 분석: ": "AI analysis: ", + "AI 상세 해설 보기": "See detailed AI explanations", + "AI 퀴즈 생성": "Make an AI quiz", + "AI 한계점:": "AI limitations:", + "AI는 높은 정확도로 문서를 분석하지만, 100% 완벽하지 않을 수 있습니다. 생성된 문제는 학습 참고용이며, 중요한 정보는 반드시 원본과 교차 확인해주세요.": "AI analyzes documents really well, but it is not perfect. The questions are for study only—double-check important info with the original.", + "OX 퀴즈": "True/False quiz", + "OX: 핵심 개념의 옳고 그름을 빠르게 점검하는 문제": "True/False: Quickly check if core concepts are right.", + "PDF 로딩 중...": "Loading PDF...", + "PDF 메타데이터 로드 실패:": "Failed to load PDF metadata:", + "PDF, PPT, Word 공부 자료로 퀴즈를 만들어 보세요. 핵심 개념을 빠르게 암기하고 시험 대비에 효과적입니다.": "Make quizzes from PDF, PPT, and Word study materials. Memorize key concepts fast and prep for exams.", + "Q-Asker 사용 중 궁금한 점이나 개선 아이디어가 있으시면 언제든지 알려주세요! 더 좋은": "If you have questions or ideas while using Q-Asker, let us know anytime! We'll keep making it better", + "Q-Asker 이메일 문의": "Email Q-Asker", + "Q-Asker: PDF, PPT, Word로 무료 AI 퀴즈 생성": "Q-Asker: Free AI quiz generation for PDF, PPT, Word", + "Q-Asker(이하 \"서비스\")는 이용자의 개인정보를 소중히 보호하며 관련 법령을 준수합니다.": "Q-Asker (the \"Service\") takes your privacy seriously and follows relevant laws.", + "Q. AI가 만든 퀴즈의 정확도는 어느 정도인가요?": "Q. How accurate are AI-generated quizzes?", + "Q. Q-Asker는 정말 무료인가요?": "Q. Is Q-Asker really free?", + "Q. 업로드한 제 파일은 안전하게 관리되나요?": "Q. Are my uploaded files managed safely?", + "Q. 이미지로 된 파일도 퀴즈로 만들 수 있나요?": "Q. Can image files be turned into quizzes?", + "가지고 계신 학습 자료로 AI 퀴즈를 만드는 가장 쉬운 방법을 알려드립니다.": "Here is the easiest way to make AI quizzes from your study materials.", + "개": "items", + "개인정보 열람, 정정, 삭제를 요청할 수 있습니다.": "You can request to view, correct, or delete your personal information.", + "개인정보 처리방침": "Privacy Policy", + "객관식": "Multiple choice", + "객관식: 개념을 비교·분석하고 적용하는 문제": "Multiple choice: Compare, analyze, and apply concepts.", + "걸린 시간": "Time spent", + "검토": "Review", + "검토 기능: ": "Review feature: ", + "검토할 문제:": "Questions to review:", + "결과 확인: ": "See results: ", + "구글 로그인": "Continue with Google", + "구글 폼 링크": "Google Form link", + "구글 폼:": "Google Form:", + "그 외 정보는 목적 달성 시 지체 없이 파기합니다.": "Any other info is deleted as soon as the purpose is met.", + "기록 삭제 실패:": "Failed to delete record:", + "기록 삭제:": "Delete record:", + "기록 삭제에 실패했습니다.": "Failed to delete record.", + "기록을 불러오는 중...": "Loading records...", + "기록을 불러오는데 실패했습니다.": "Failed to load records.", + "기록이 삭제되었습니다.": "Record deleted.", + "까지 자동으로 저장됩니다": "is auto-saved up to", + "나중에 다시 볼 문제에 체크 표시": "Mark questions to revisit later", + "내 퀴즈 기록": "My quiz history", + "네, PDF, PPT, Word 기반 AI 퀴즈 생성은 현재 완전 무료입니다. 별도의 회원가입 없이 누구나 자유롭게 이용할 수 있습니다.": "Yes. AI quiz generation for PDF, PPT, and Word is completely free right now. Anyone can use it without signing up.", + "네. OCR을 지원하여 스캔 본이나 사진 형태의 문서도 분석할 수 있습니다.": "Yes. We support OCR, so scanned or photographed documents can be analyzed.", + "네. 업로드된 파일은 퀴즈 생성을 위해서만 일시적으로 사용되며, 24시간 뒤에 삭제됩니다.": "Yes. Uploaded files are used temporarily only for quiz creation and are deleted after 24 hours.", + "네비게이션: ": "Navigation: ", + "다른 문제 생성": "Make another quiz", + "다른 파일 넣기": "Upload another file", + "다만, 법령에 따라 요청되는 경우 제공될 수 있습니다.": "However, it may be provided if required by law.", + "다시 풀기": "Try again", + "다음": "Next", + "닫기": "Close", + "답변한 문제:": "Answered questions:", + "더 많은 페이지 로딩 중... (": "Loading more pages... (", + "도움말 보기": "Help", + "되어 해설을 볼 수\n 없게 됩니다": "so you will not be able to see the explanations\n anymore", + "또는": "or", + "로그아웃": "Log out", + "로그아웃되었습니다.": "Logged out.", + "로그아웃에 실패했습니다.": "Failed to log out.", + "로그인": "Log in", + "로그인 리다이렉트 실패:": "Login redirect failed:", + "로그인 처리 중...": "Logging you in...", + "로그인에 실패했습니다. 다시 로그인해주세요.": "Log in failed. Try logging in again.", + "로그인에 실패했습니다. 다시 시도해주세요.": "Log in failed. Try again.", + "로그인하고, 퀴즈기록을 저장해보세요": "Log in to save your quiz history", + "로딩 중…": "Loading...", + "로딩...": "Loading...", + "마지막 문제입니다.": "This is the last question.", + "만든 퀴즈가 퀴즈 기록에 자동 저장": "Quizzes you make are auto-saved to your history", + "메뉴": "Menu", + "모든 기록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.": "Delete all records? This action cannot be undone.", + "모든 기록이 삭제되었습니다.": "All records have been deleted.", + "모든 자료는 업로드 이후 24시간 뒤에 삭제됩니다": "All materials are deleted 24 hours after upload", + "문서를 분석하고 문제를 생성하려면 아래 버튼을 클릭하세요.": "Click the button below to analyze the document and generate questions.", + "문의 및 고객지원 대응": "Help & support", + "문의 및 피드백": "Questions & feedback", + "문의 시: 이메일 주소, 문의 내용": "For inquiries: your email and message", + "문의는 아래 연락처로 접수됩니다.": "Reach us at the contact below.", + "문제": "Question", + "문제 개수: ": "Questions: ", + "문제 로딩 중…": "Loading questions...", + "문제 생성 중 오류가 발생했습니다.": "Something went wrong while generating questions.", + "문제 생성 중...": "Generating questions...", + "문제 생성하기": "Make questions", + "문제 수": "Question count", + "문제 수량: ": "Question count: ", + "문제 유형: ": "Question type: ", + "문제 유형별 기준에 맞춰 퀴즈를 생성합니다.": "Quizzes are generated based on each type's rules.", + "문제 풀기": "Take quiz", + "문제 풀이: ": "Solving: ", + "문제별 자세한 설명과 참조한 페이지 미리보기 제공": "Get detailed explanations and previews of referenced pages", + "미리보기 끄기": "Hide preview", + "미리보기 켜기": "Show preview", + "미선택": "Not selected", + "미완료": "Incomplete", + "반복 학습: ": "Repeat learning: ", + "번:": "No.:", + "변경사항 로드 실패:": "Failed to load changes:", + "변환 시간 초과": "Conversion timed out", + "보안 및 부정 이용 방지": "Security and abuse prevention", + "보통 10초 ~ 30초 (문서 길이에 따라 다름)": "Usually 10 to 30 seconds (depends on document length)", + "복습: ": "Review: ", + "본 방침은 서비스 개선에 따라 변경될 수 있으며, 변경 시 공지합니다.": "This policy may change due to service improvements, and changes will be announced.", + "빈칸 넣기": "Add a blank", + "빈칸 채우기, OX, 객관식 중 선택": "Choose from fill-in-the-blank, true/false, or multiple choice", + "빈칸, OX, 객관식 유형을 번갈아 풀어보며 개념 이해와 기억을 균형 있게 강화하세요.": "Alternate between fill-in-the-blank, true/false, and multiple choice to balance understanding and memory.", + "빈칸: 핵심 개념을 정확히 기억하는지 확인": "Fill-in-the-blank: Check if you remember key concepts accurately.", + "사용자": "User", + "삭제": "Delete", + "삭제된 퀴즈 기록은 복구할 수 없으니 신중하게 결정해주세요.": "Deleted quiz records cannot be restored, so be careful.", + "상세 해설": "Detailed explanation", + "상세 해설: ": "Detailed explanation: ", + "상세 해설을 불러오는데 실패했습니다.": "Failed to load detailed explanation.", + "생성 중...": "Generating...", + "생성:": "Generated:", + "생성된 객관식 문제를 순서대로 풀이": "Solve generated multiple-choice questions in order", + "생성된 문제": "Generated questions", + "생성된 문제는 학습 참고용이며, 사실관계가 100% 정확하지 않을 수 있습니다. 중요한 정보는 반드시 원본과 교차 확인하세요.": "Generated questions are for study reference and may not be 100% accurate. Double-check important info with the original.", + "생성된 문제의 개수는 간혹 지정한 개수와 맞지 않을 수 있습니다.": "The number of generated questions may sometimes differ from the specified count.", + "서비스 운영에 필요한 범위 내에서 일부 업무를 위탁할 수 있습니다.": "Some tasks may be outsourced within the scope necessary to operate the service.", + "서비스 이용 시 자동 수집: IP 주소, 브라우저 정보, 접속 로그": "Automatically collected during service use: IP address, browser info, access logs", + "서비스 제공 및 기능 개선": "Provide service and improve features", + "서비스를 만드는데 큰 도움이 됩니다.": "This really helps us improve the service.", + "선택된 페이지 수: ": "Selected page count: ", + "선택한 답:": "Selected answer:", + "선택한 답안": "Selected answer", + "선택할 수 있어요": "You can choose", + "성과 확인: ": "Check performance: ", + "소요 시간: ": "Time spent: ", + "슬라이드의": "of the slides", + "시행일: 2026-01-30": "Effective date: 2026-01-30", + "아직 만든 퀴즈가 없습니다": "No quizzes yet", + "안푼 문제:": "Unanswered questions:", + "언어": "Language", + "언제든 이어서 풀거나 해설 다시 보기": "Pick up anytime or view explanations again", + "업로드 ": "Upload ", + "업로드된 문서를 AI가 분석하여 문제 생성": "AI analyzes uploaded documents to generate questions", + "업로드한 파일: 퀴즈 생성 목적의 처리 과정에서 일시적으로 저장": "Uploaded file: stored temporarily during processing for quiz generation", + "업로드한 파일은 처리 후 24시간 이내 자동 삭제됩니다.": "Uploaded files are automatically deleted within 24 hours after processing.", + "에러 상세 정보:": "Error details:", + "오답": "Incorrect", + "오답이 없습니다!": "No wrong answers!", + "완료": "Complete", + "완료:": "Complete:", + "완료: ": "Complete: ", + "완료!": "Complete!", + "완료된 퀴즈만 해설을 볼 수 있습니다.": "Only completed quizzes can show explanations.", + "완료율": "Completion rate", + "완료한 퀴즈": "Completed quizzes", + "원칙적으로 제3자에게 제공하지 않습니다.": "We do not share it with third parties as a rule.", + "원하는 페이지 입력:": "Enter the pages you want:", + "원하는 페이지를 지정하면 AI 퀴즈 생성시 더 좋은 퀴즈를 만들 수 있습니다.": "Specifying desired pages helps AI generate better quizzes.", + "위탁 시 관련 법령에 따라 관리·감독합니다.": "We manage and supervise outsourcing in accordance with relevant laws.", + "유효한 퀴즈 정보가 없습니다. 홈으로 이동합니다.": "No valid quiz info. Going home.", + "이 기록을 삭제하시겠습니까?": "Delete this record?", + "이메일:": "Email:", + "이메일: inhapj01@gmail.com": "Email: inhapj01@gmail.com", + "이전": "Previous", + "입력 X": "Enter X", + "자동 저장: ": "Auto-save: ", + "적용": "Apply", + "전체 기록 삭제 실패:": "Failed to delete all records:", + "전체 또는 특정 페이지 지정": "Select all or specific pages", + "전체 문제:": "Total questions:", + "전체 삭제": "Delete all", + "전체 선택": "Select all", + "전체 해제": "Deselect all", + "점": "pt", + "점 (": "pts (", + "점수": "Score", + "점수, 소요시간 등 결과 확인": "See results like score and time spent", + "정답": "Correct", + "정답 답안:": "Correct answer:", + "제출 확인": "Confirm submit", + "제출하기": "Submit", + "좌측 번호판으로 빠른 이동": "Jump quickly with the left number panel", + "지금까지 만들고 푼 퀴즈들을 확인해보세요": "Check out the quizzes you've made and solved so far", + "지원 형식: ": "Supported formats: ", + "지원하는 파일": "Supported files", + "지원하지 않는 파일 형식입니다": "Unsupported file format", + "초": "sec", + "총 퀴즈 수": "Total quizzes", + "총 퀴즈 수, 평균 점수 등 확인": "See total quizzes, average score, and more", + "최근 변경사항": "Recent changes", + "최대 ": "Up to ", + "최신 퀴즈 로딩 실패:": "Failed to load latest quiz:", + "취소": "Cancel", + "카카오 로그인": "Continue with Kakao", + "퀴즈 결과 기록 업데이트 실패:": "Failed to update quiz result record:", + "퀴즈 기록": "Quiz history", + "퀴즈 기록 불러오기 실패:": "Failed to load quiz history:", + "퀴즈 기록 저장 실패:": "Failed to save quiz history:", + "퀴즈 만들기": "Make a quiz", + "퀴즈 보관 정책": "Quiz retention policy", + "퀴즈 풀기": "Take quiz", + "퀴즈를 만들어서 문제를 풀어보세요!": "Make a quiz and solve the questions!", + "크기 제한": "Size limit", + "틀린 문제 중심 재학습 가능": "Review with a focus on incorrect questions", + "팁: ": "Tip: ", + "파일 링크가 만료되었습니다.": "The file link has expired.", + "파일 변환이 지연되고 있어요. 잠시 후 다시 시도해주세요.": "File conversion is taking longer. Try again in a bit.", + "파일 선택하기": "Choose a file", + "파일 업로드 실패:": "File upload failed:", + "파일 업로드 중 오류가 발생했습니다. 다시 시도해주세요.": "Something went wrong during upload. Try again.", + "파일 업로드 중...": "Uploading file...", + "파일 크기에 따라 시간이 소요될 수 있습니다": "Time may vary depending on file size", + "파일은 상업적 목적, AI 학습 목적으로 사용되지 않습니다.": "Files are not used for commercial purposes or AI training.", + "파일을 PDF로 변환하고 있어요": "Converting file to PDF", + "파일을 드래그하거나 버튼 클릭": "Drag the file or click the button", + "파일을 먼저 업로드해주세요.": "Upload a file first.", + "파일을 여기에 드래그하세요": "Drag the file here", + "파일이 존재하지 않습니다.": "File does not exist.", + "페이지": "Page", + "페이지 범위: ": "Page range: ", + "페이지 범위를 올바르게 입력해주세요.": "Enter a valid page range.", + "페이지 정보를 불러오는 중입니다. 잠시만 기다려주세요.": "Loading page info. Hang tight.", + "페이지를 선택해주세요.": "Select pages.", + "평균 점수": "Average score", + "학습 자료 파일": "Study material file", + "해설": "Explanation", + "해설 데이터 로딩 실패:": "Failed to load explanation data:", + "해설 보기": "See explanation", + "해설을 불러오는데 실패했습니다. 문제가 삭제되었을 수 있습니다.": "Couldn't load the explanation. The question might have been deleted.", + "해설이 없습니다.": "No explanation.", + "현재 생성중입니다 조금만 더 기다려주세요!": "Generating now. Hang tight!", + "현재는 pdf 파일만 지원합니다.": "Only PDF files are supported for now.", + "홈으로": "Home", + "확인": "OK" +} \ No newline at end of file diff --git a/src/shared/i18n/index.js b/src/shared/i18n/index.js new file mode 100644 index 0000000..2dad6ad --- /dev/null +++ b/src/shared/i18n/index.js @@ -0,0 +1,7 @@ +import en from "./en.json"; +import ko from "./ko.json"; + +export const translations = { + en, + ko, +}; diff --git a/locales/index.ts b/src/shared/i18n/index.ts similarity index 100% rename from locales/index.ts rename to src/shared/i18n/index.ts diff --git a/locales/ko.json b/src/shared/i18n/ko.json similarity index 58% rename from locales/ko.json rename to src/shared/i18n/ko.json index 22c4e46..9cbf338 100644 --- a/locales/ko.json +++ b/src/shared/i18n/ko.json @@ -1,71 +1,80 @@ { + " 페이지": " 페이지", "\"문제 풀기\" 버튼으로": "\"문제 풀기\" 버튼으로", "• 생성된 퀴즈는": "• 생성된 퀴즈는", "• 중요한 퀴즈는 생성 후 24시간 내에 완료하여 기록을\n 남겨두시기 바랍니다": "• 중요한 퀴즈는 생성 후 24시간 내에 완료하여 기록을\n 남겨두시기 바랍니다", "• 퀴즈 기록은 최대": "• 퀴즈 기록은 최대", - "=== 퀴즈 완료 데이터 저장 ===": "=== 퀴즈 완료 데이터 저장 ===", - "=== 퀴즈 통계 정보 ===": "=== 퀴즈 통계 정보 ===", - "=== 퀴즈 히스토리 전체 데이터 ===": "=== 퀴즈 히스토리 전체 데이터 ===", - "=== 해설 페이지 이동 시작 ===": "=== 해설 페이지 이동 시작 ===", "⏰ 자료 보호": "⏰ 자료 보호", "✕ 파일 삭제": "✕ 파일 삭제", "❌ 오답만": "❌ 오답만", "🌟 Q-Asker를 신뢰할 수 있는 이유": "🌟 Q-Asker를 신뢰할 수 있는 이유", "💡 AI 퀴즈 활용 200% 팁": "💡 AI 퀴즈 활용 200% 팁", "📄 관련 슬라이드": "📄 관련 슬라이드", - "📈 단계별 풀어보기": "📈 단계별 풀어보기", + "📈 유형별 풀어보기": "📈 유형별 풀어보기", "📋 명확한 문제 생성 기준": "📋 명확한 문제 생성 기준", "📚 참조 페이지": "📚 참조 페이지", - "📝 PDF/PPT로 AI 퀴즈 만들기 6단계 가이드": "📝 PDF/PPT로 AI 퀴즈 만들기 6단계 가이드", + "📝 AI 퀴즈 만들기 6단계 가이드": "📝 AI 퀴즈 만들기 6단계 가이드", "📞 문의 및 피드백": "📞 문의 및 피드백", "🔄 복습 퀴즈": "🔄 복습 퀴즈", "🙋‍♀️ 자주 묻는 질문 (FAQ)": "🙋‍♀️ 자주 묻는 질문 (FAQ)", "🚨 꼭 읽어주세요: 주의사항": "🚨 꼭 읽어주세요: 주의사항", - "🚨파일은 상업적 목적, AI 학습 목적으로 사용되지 않습니다.": "🚨파일은 상업적 목적, AI 학습 목적으로 사용되지 않습니다.", - "1. Easy 단계를 통해 핵심 개념을 암기하세요.": "1. Easy 단계를 통해 핵심 개념을 암기하세요.", + "1. 빈칸 채우기로 핵심 개념을 정리하세요.": "1. 빈칸 채우기로 핵심 개념을 정리하세요.", + "1. 수집하는 개인정보": "1. 수집하는 개인정보", + "1. 퀴즈 타입을 선택하세요!": "1. 퀴즈 타입을 선택하세요!", "1단계: 파일 업로드": "1단계: 파일 업로드", - "2. Normal 단계를 통해 간단한 맥락에 적용하세요.": "2. Normal 단계를 통해 간단한 맥락에 적용하세요.", + "2. OX로 빠르게 개념을 점검하세요.": "2. OX로 빠르게 개념을 점검하세요.", + "2. 개인정보의 이용 목적": "2. 개인정보의 이용 목적", + "2. 문제 개수를 지정하세요!": "2. 문제 개수를 지정하세요!", "20개": "20개", "24시간 후 서버에서 자동 삭제": "24시간 후 서버에서 자동 삭제", "24시간 후 자동 삭제되며 별도로 저장, 공유되지 않습니다.": "24시간 후 자동 삭제되며 별도로 저장, 공유되지 않습니다.", "2단계: 퀴즈 옵션 설정": "2단계: 퀴즈 옵션 설정", - "3. Hard 단계를 통해 깊은 추론을 요구하는 문제를 풀어보세요.": "3. Hard 단계를 통해 깊은 추론을 요구하는 문제를 풀어보세요.", + "3. 객관식으로 개념을 응용해 보세요.": "3. 객관식으로 개념을 응용해 보세요.", + "3. 보관 및 이용 기간": "3. 보관 및 이용 기간", + "3. 특정 페이지를 지정하세요!": "3. 특정 페이지를 지정하세요!", "3단계: AI 문제 생성": "3단계: AI 문제 생성", + "4. 문제를 생성하세요!": "4. 문제를 생성하세요!", + "4. 제3자 제공": "4. 제3자 제공", "4단계: 퀴즈 풀기": "4단계: 퀴즈 풀기", "5 ~ 25개 (5개 단위)": "5 ~ 25개 (5개 단위)", + "5. 개인정보 처리 위탁": "5. 개인정보 처리 위탁", "5단계: 결과 및 해설 확인": "5단계: 결과 및 해설 확인", + "6. 이용자 권리": "6. 이용자 권리", "6단계: 퀴즈 기록 관리": "6단계: 퀴즈 기록 관리", + "7. 문의처": "7. 문의처", "AI 분석: ": "AI 분석: ", "AI 상세 해설 보기": "AI 상세 해설 보기", "AI 퀴즈 생성": "AI 퀴즈 생성", "AI 한계점:": "AI 한계점:", "AI는 높은 정확도로 문서를 분석하지만, 100% 완벽하지 않을 수 있습니다. 생성된 문제는 학습 참고용이며, 중요한 정보는 반드시 원본과 교차 확인해주세요.": "AI는 높은 정확도로 문서를 분석하지만, 100% 완벽하지 않을 수 있습니다. 생성된 문제는 학습 참고용이며, 중요한 정보는 반드시 원본과 교차 확인해주세요.", - "Easy: 순수 암기나 단순 이해를 묻는 문제": "Easy: 순수 암기나 단순 이해를 묻는 문제", - "Hard: 한 단계 더 깊은 추론, 문제 해결, 자료 해석 등을 요구": "Hard: 한 단계 더 깊은 추론, 문제 해결, 자료 해석 등을 요구", - "Normal: 주어진 개념을 간단한 맥락에 적용하거나 비교·분석하게 하는 문제": "Normal: 주어진 개념을 간단한 맥락에 적용하거나 비교·분석하게 하는 문제", - "OCR 변환하기": "OCR 변환하기", "OX 퀴즈": "OX 퀴즈", + "OX: 핵심 개념의 옳고 그름을 빠르게 점검하는 문제": "OX: 핵심 개념의 옳고 그름을 빠르게 점검하는 문제", "PDF 로딩 중...": "PDF 로딩 중...", - "PDF, PPT 공부 자료로 퀴즈를 만들어 보세요. 핵심 개념을 빠르게 암기하고 시험 대비에 효과적입니다.": "PDF, PPT 공부 자료로 퀴즈를 만들어 보세요. 핵심 개념을 빠르게 암기하고 시험 대비에 효과적입니다.", + "PDF 메타데이터 로드 실패:": "PDF 메타데이터 로드 실패:", + "PDF, PPT, Word 공부 자료로 퀴즈를 만들어 보세요. 핵심 개념을 빠르게 암기하고 시험 대비에 효과적입니다.": "PDF, PPT, Word 공부 자료로 퀴즈를 만들어 보세요. 핵심 개념을 빠르게 암기하고 시험 대비에 효과적입니다.", "Q-Asker 사용 중 궁금한 점이나 개선 아이디어가 있으시면 언제든지 알려주세요! 더 좋은": "Q-Asker 사용 중 궁금한 점이나 개선 아이디어가 있으시면 언제든지 알려주세요! 더 좋은", "Q-Asker 이메일 문의": "Q-Asker 이메일 문의", - "Q-Asker: PDF/PPT 파일로 무료 AI 퀴즈 생성": "Q-Asker: PDF/PPT 파일로 무료 AI 퀴즈 생성", + "Q-Asker: PDF, PPT, Word로 무료 AI 퀴즈 생성": "Q-Asker: PDF, PPT, Word로 무료 AI 퀴즈 생성", + "Q-Asker(이하 \"서비스\")는 이용자의 개인정보를 소중히 보호하며 관련 법령을 준수합니다.": "Q-Asker(이하 \"서비스\")는 이용자의 개인정보를 소중히 보호하며 관련 법령을 준수합니다.", "Q. AI가 만든 퀴즈의 정확도는 어느 정도인가요?": "Q. AI가 만든 퀴즈의 정확도는 어느 정도인가요?", "Q. Q-Asker는 정말 무료인가요?": "Q. Q-Asker는 정말 무료인가요?", "Q. 업로드한 제 파일은 안전하게 관리되나요?": "Q. 업로드한 제 파일은 안전하게 관리되나요?", - "Q. 이미지로 된 PDF 파일도 퀴즈로 만들 수 있나요?": "Q. 이미지로 된 PDF 파일도 퀴즈로 만들 수 있나요?", - "Webb's Dok 이론에 기반한 단계별 풀어보기 기능을 활용해 한 단계씩 순서대로 풀어보세요.": "Webb's Dok 이론에 기반한 단계별 풀어보기 기능을 활용해 한 단계씩 순서대로 풀어보세요.", - "Webb's dok 이론에 기반한 문제 생성 기준을 통해 문제를 생성합니다.": "Webb's dok 이론에 기반한 문제 생성 기준을 통해 문제를 생성합니다.", - "가지고 계신 학습 자료(PDF, PPT)로 AI 퀴즈를 만드는 가장 쉬운 방법을 알려드립니다.": "가지고 계신 학습 자료(PDF, PPT)로 AI 퀴즈를 만드는 가장 쉬운 방법을 알려드립니다.", + "Q. 이미지로 된 파일도 퀴즈로 만들 수 있나요?": "Q. 이미지로 된 파일도 퀴즈로 만들 수 있나요?", + "가지고 계신 학습 자료로 AI 퀴즈를 만드는 가장 쉬운 방법을 알려드립니다.": "가지고 계신 학습 자료로 AI 퀴즈를 만드는 가장 쉬운 방법을 알려드립니다.", "개": "개", + "개인정보 열람, 정정, 삭제를 요청할 수 있습니다.": "개인정보 열람, 정정, 삭제를 요청할 수 있습니다.", + "개인정보 처리방침": "개인정보 처리방침", "객관식": "객관식", + "객관식: 개념을 비교·분석하고 적용하는 문제": "객관식: 개념을 비교·분석하고 적용하는 문제", "걸린 시간": "걸린 시간", "검토": "검토", "검토 기능: ": "검토 기능: ", "검토할 문제:": "검토할 문제:", "결과 확인: ": "결과 확인: ", + "구글 로그인": "구글 로그인", "구글 폼 링크": "구글 폼 링크", "구글 폼:": "구글 폼:", + "그 외 정보는 목적 달성 시 지체 없이 파기합니다.": "그 외 정보는 목적 달성 시 지체 없이 파기합니다.", "기록 삭제 실패:": "기록 삭제 실패:", "기록 삭제:": "기록 삭제:", "기록 삭제에 실패했습니다.": "기록 삭제에 실패했습니다.", @@ -74,20 +83,31 @@ "기록이 삭제되었습니다.": "기록이 삭제되었습니다.", "까지 자동으로 저장됩니다": "까지 자동으로 저장됩니다", "나중에 다시 볼 문제에 체크 표시": "나중에 다시 볼 문제에 체크 표시", - "난이도: ": "난이도: ", "내 퀴즈 기록": "내 퀴즈 기록", - "네, PDF/PPT 기반 AI 퀴즈 생성은 현재 완전 무료입니다. 별도의 회원가입 없이 누구나 자유롭게 이용할 수 있습니다.": "네, PDF/PPT 기반 AI 퀴즈 생성은 현재 완전 무료입니다. 별도의 회원가입 없이 누구나 자유롭게 이용할 수 있습니다.", + "네, PDF, PPT, Word 기반 AI 퀴즈 생성은 현재 완전 무료입니다. 별도의 회원가입 없이 누구나 자유롭게 이용할 수 있습니다.": "네, PDF, PPT, Word 기반 AI 퀴즈 생성은 현재 완전 무료입니다. 별도의 회원가입 없이 누구나 자유롭게 이용할 수 있습니다.", + "네. OCR을 지원하여 스캔 본이나 사진 형태의 문서도 분석할 수 있습니다.": "네. OCR을 지원하여 스캔 본이나 사진 형태의 문서도 분석할 수 있습니다.", "네. 업로드된 파일은 퀴즈 생성을 위해서만 일시적으로 사용되며, 24시간 뒤에 삭제됩니다.": "네. 업로드된 파일은 퀴즈 생성을 위해서만 일시적으로 사용되며, 24시간 뒤에 삭제됩니다.", "네비게이션: ": "네비게이션: ", "다른 문제 생성": "다른 문제 생성", "다른 파일 넣기": "다른 파일 넣기", + "다만, 법령에 따라 요청되는 경우 제공될 수 있습니다.": "다만, 법령에 따라 요청되는 경우 제공될 수 있습니다.", "다시 풀기": "다시 풀기", "다음": "다음", + "닫기": "닫기", "답변한 문제:": "답변한 문제:", "더 많은 페이지 로딩 중... (": "더 많은 페이지 로딩 중... (", "도움말 보기": "도움말 보기", "되어 해설을 볼 수\n 없게 됩니다": "되어 해설을 볼 수\n 없게 됩니다", "또는": "또는", + "로그아웃": "로그아웃", + "로그아웃되었습니다.": "로그아웃되었습니다.", + "로그아웃에 실패했습니다.": "로그아웃에 실패했습니다.", + "로그인": "로그인", + "로그인 리다이렉트 실패:": "로그인 리다이렉트 실패:", + "로그인 처리 중...": "로그인 처리 중...", + "로그인에 실패했습니다. 다시 로그인해주세요.": "로그인에 실패했습니다. 다시 로그인해주세요.", + "로그인에 실패했습니다. 다시 시도해주세요.": "로그인에 실패했습니다. 다시 시도해주세요.", + "로그인하고, 퀴즈기록을 저장해보세요": "로그인하고, 퀴즈기록을 저장해보세요", "로딩 중…": "로딩 중…", "로딩...": "로딩...", "마지막 문제입니다.": "마지막 문제입니다.", @@ -96,59 +116,72 @@ "모든 기록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.": "모든 기록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", "모든 기록이 삭제되었습니다.": "모든 기록이 삭제되었습니다.", "모든 자료는 업로드 이후 24시간 뒤에 삭제됩니다": "모든 자료는 업로드 이후 24시간 뒤에 삭제됩니다", - "문서 미리보기": "문서 미리보기", "문서를 분석하고 문제를 생성하려면 아래 버튼을 클릭하세요.": "문서를 분석하고 문제를 생성하려면 아래 버튼을 클릭하세요.", + "문의 및 고객지원 대응": "문의 및 고객지원 대응", "문의 및 피드백": "문의 및 피드백", + "문의 시: 이메일 주소, 문의 내용": "문의 시: 이메일 주소, 문의 내용", + "문의는 아래 연락처로 접수됩니다.": "문의는 아래 연락처로 접수됩니다.", "문제": "문제", - "문제 개수:": "문제 개수:", - "문제 단계 설정하기": "문제 단계 설정하기", + "문제 개수: ": "문제 개수: ", "문제 로딩 중…": "문제 로딩 중…", + "문제 생성 중 오류가 발생했습니다.": "문제 생성 중 오류가 발생했습니다.", "문제 생성 중...": "문제 생성 중...", "문제 생성하기": "문제 생성하기", "문제 수": "문제 수", - "문제 수량:": "문제 수량:", "문제 수량: ": "문제 수량: ", + "문제 유형: ": "문제 유형: ", + "문제 유형별 기준에 맞춰 퀴즈를 생성합니다.": "문제 유형별 기준에 맞춰 퀴즈를 생성합니다.", "문제 풀기": "문제 풀기", "문제 풀이: ": "문제 풀이: ", "문제별 자세한 설명과 참조한 페이지 미리보기 제공": "문제별 자세한 설명과 참조한 페이지 미리보기 제공", - "문제셋 ID:": "문제셋 ID:", - "미리보기 및 페이지 선택": "미리보기 및 페이지 선택", + "미리보기 끄기": "미리보기 끄기", + "미리보기 켜기": "미리보기 켜기", "미선택": "미선택", "미완료": "미완료", "반복 학습: ": "반복 학습: ", "번:": "번:", + "변경사항 로드 실패:": "변경사항 로드 실패:", + "변환 시간 초과": "변환 시간 초과", + "보안 및 부정 이용 방지": "보안 및 부정 이용 방지", "보통 10초 ~ 30초 (문서 길이에 따라 다름)": "보통 10초 ~ 30초 (문서 길이에 따라 다름)", "복습: ": "복습: ", - "빈칸 채우기(Easy), OX(Normal), 객관식(Hard) 중 선택": "빈칸 채우기(Easy), OX(Normal), 객관식(Hard) 중 선택", - "사용자 지정": "사용자 지정", + "본 방침은 서비스 개선에 따라 변경될 수 있으며, 변경 시 공지합니다.": "본 방침은 서비스 개선에 따라 변경될 수 있으며, 변경 시 공지합니다.", + "빈칸 넣기": "빈칸 넣기", + "빈칸 채우기, OX, 객관식 중 선택": "빈칸 채우기, OX, 객관식 중 선택", + "빈칸, OX, 객관식 유형을 번갈아 풀어보며 개념 이해와 기억을 균형 있게 강화하세요.": "빈칸, OX, 객관식 유형을 번갈아 풀어보며 개념 이해와 기억을 균형 있게 강화하세요.", + "빈칸: 핵심 개념을 정확히 기억하는지 확인": "빈칸: 핵심 개념을 정확히 기억하는지 확인", + "사용자": "사용자", "삭제": "삭제", "삭제된 퀴즈 기록은 복구할 수 없으니 신중하게 결정해주세요.": "삭제된 퀴즈 기록은 복구할 수 없으니 신중하게 결정해주세요.", "상세 해설": "상세 해설", "상세 해설: ": "상세 해설: ", "상세 해설을 불러오는데 실패했습니다.": "상세 해설을 불러오는데 실패했습니다.", - "상태:": "상태:", "생성 중...": "생성 중...", "생성:": "생성:", "생성된 객관식 문제를 순서대로 풀이": "생성된 객관식 문제를 순서대로 풀이", - "생성된 문제 데이터:": "생성된 문제 데이터:", + "생성된 문제": "생성된 문제", "생성된 문제는 학습 참고용이며, 사실관계가 100% 정확하지 않을 수 있습니다. 중요한 정보는 반드시 원본과 교차 확인하세요.": "생성된 문제는 학습 참고용이며, 사실관계가 100% 정확하지 않을 수 있습니다. 중요한 정보는 반드시 원본과 교차 확인하세요.", - "생성일:": "생성일:", + "생성된 문제의 개수는 간혹 지정한 개수와 맞지 않을 수 있습니다.": "생성된 문제의 개수는 간혹 지정한 개수와 맞지 않을 수 있습니다.", + "서비스 운영에 필요한 범위 내에서 일부 업무를 위탁할 수 있습니다.": "서비스 운영에 필요한 범위 내에서 일부 업무를 위탁할 수 있습니다.", + "서비스 이용 시 자동 수집: IP 주소, 브라우저 정보, 접속 로그": "서비스 이용 시 자동 수집: IP 주소, 브라우저 정보, 접속 로그", + "서비스 제공 및 기능 개선": "서비스 제공 및 기능 개선", "서비스를 만드는데 큰 도움이 됩니다.": "서비스를 만드는데 큰 도움이 됩니다.", - "선택된 기록:": "선택된 기록:", + "선택된 페이지 수: ": "선택된 페이지 수: ", "선택한 답:": "선택한 답:", "선택한 답안": "선택한 답안", + "선택할 수 있어요": "선택할 수 있어요", "성과 확인: ": "성과 확인: ", "소요 시간: ": "소요 시간: ", "슬라이드의": "슬라이드의", - "아니요. 현재는 텍스트 선택이 가능한 '텍스트 기반'의 PDF, PPT, PPTX 파일만 지원합니다. 스캔 본이나 사진 형태의 문서는 분석이 어렵습니다.": "아니요. 현재는 텍스트 선택이 가능한 '텍스트 기반'의 PDF, PPT, PPTX 파일만 지원합니다. 스캔 본이나 사진 형태의 문서는 분석이 어렵습니다.", + "시행일: 2026-01-30": "시행일: 2026-01-30", "아직 만든 퀴즈가 없습니다": "아직 만든 퀴즈가 없습니다", "안푼 문제:": "안푼 문제:", "언어": "언어", "언제든 이어서 풀거나 해설 다시 보기": "언제든 이어서 풀거나 해설 다시 보기", - "업데이트된 히스토리:": "업데이트된 히스토리:", "업로드 ": "업로드 ", - "업로드 URL:": "업로드 URL:", "업로드된 문서를 AI가 분석하여 문제 생성": "업로드된 문서를 AI가 분석하여 문제 생성", + "업로드한 파일: 퀴즈 생성 목적의 처리 과정에서 일시적으로 저장": "업로드한 파일: 퀴즈 생성 목적의 처리 과정에서 일시적으로 저장", + "업로드한 파일은 처리 후 24시간 이내 자동 삭제됩니다.": "업로드한 파일은 처리 후 24시간 이내 자동 삭제됩니다.", "에러 상세 정보:": "에러 상세 정보:", "오답": "오답", "오답이 없습니다!": "오답이 없습니다!", @@ -156,79 +189,67 @@ "완료:": "완료:", "완료: ": "완료: ", "완료!": "완료!", - "완료된 퀴즈 배열:": "완료된 퀴즈 배열:", - "완료된 퀴즈 수:": "완료된 퀴즈 수:", "완료된 퀴즈만 해설을 볼 수 있습니다.": "완료된 퀴즈만 해설을 볼 수 있습니다.", "완료율": "완료율", - "완료율:": "완료율:", - "완료일:": "완료일:", "완료한 퀴즈": "완료한 퀴즈", + "원칙적으로 제3자에게 제공하지 않습니다.": "원칙적으로 제3자에게 제공하지 않습니다.", + "원하는 페이지 입력:": "원하는 페이지 입력:", "원하는 페이지를 지정하면 AI 퀴즈 생성시 더 좋은 퀴즈를 만들 수 있습니다.": "원하는 페이지를 지정하면 AI 퀴즈 생성시 더 좋은 퀴즈를 만들 수 있습니다.", + "위탁 시 관련 법령에 따라 관리·감독합니다.": "위탁 시 관련 법령에 따라 관리·감독합니다.", "유효한 퀴즈 정보가 없습니다. 홈으로 이동합니다.": "유효한 퀴즈 정보가 없습니다. 홈으로 이동합니다.", "이 기록을 삭제하시겠습니까?": "이 기록을 삭제하시겠습니까?", "이메일:": "이메일:", + "이메일: inhapj01@gmail.com": "이메일: inhapj01@gmail.com", "이전": "이전", "입력 X": "입력 X", "자동 저장: ": "자동 저장: ", - "저장된 퀴즈 데이터 사용:": "저장된 퀴즈 데이터 사용:", - "저장된 퀴즈 데이터가 없음. API로 데이터 가져오기": "저장된 퀴즈 데이터가 없음. API로 데이터 가져오기", - "저장할 퀴즈 데이터:": "저장할 퀴즈 데이터:", - "전체": "전체", - "전체 기록 배열:": "전체 기록 배열:", + "적용": "적용", "전체 기록 삭제 실패:": "전체 기록 삭제 실패:", - "전체 데이터:": "전체 데이터:", "전체 또는 특정 페이지 지정": "전체 또는 특정 페이지 지정", "전체 문제:": "전체 문제:", "전체 삭제": "전체 삭제", "전체 선택": "전체 선택", - "전체 퀴즈 수:": "전체 퀴즈 수:", + "전체 해제": "전체 해제", "점": "점", "점 (": "점 (", "점수": "점수", "점수, 소요시간 등 결과 확인": "점수, 소요시간 등 결과 확인", - "점수:": "점수:", "정답": "정답", "정답 답안:": "정답 답안:", "제출 확인": "제출 확인", "제출하기": "제출하기", "좌측 번호판으로 빠른 이동": "좌측 번호판으로 빠른 이동", "지금까지 만들고 푼 퀴즈들을 확인해보세요": "지금까지 만들고 푼 퀴즈들을 확인해보세요", - "지원 파일 형식: PPT, PPTX, PDF": "지원 파일 형식: PPT, PPTX, PDF", "지원 형식: ": "지원 형식: ", + "지원하는 파일": "지원하는 파일", "지원하지 않는 파일 형식입니다": "지원하지 않는 파일 형식입니다", "초": "초", - "총 기록 개수:": "총 기록 개수:", "총 퀴즈 수": "총 퀴즈 수", "총 퀴즈 수, 평균 점수 등 확인": "총 퀴즈 수, 평균 점수 등 확인", + "최근 변경사항": "최근 변경사항", + "최대 ": "최대 ", "최신 퀴즈 로딩 실패:": "최신 퀴즈 로딩 실패:", - "최종 퀴즈 배열:": "최종 퀴즈 배열:", "취소": "취소", + "카카오 로그인": "카카오 로그인", "퀴즈 결과 기록 업데이트 실패:": "퀴즈 결과 기록 업데이트 실패:", "퀴즈 기록": "퀴즈 기록", "퀴즈 기록 불러오기 실패:": "퀴즈 기록 불러오기 실패:", "퀴즈 기록 저장 실패:": "퀴즈 기록 저장 실패:", - "퀴즈 데이터 길이:": "퀴즈 데이터 길이:", - "퀴즈 데이터 응답:": "퀴즈 데이터 응답:", - "퀴즈 데이터 존재 여부:": "퀴즈 데이터 존재 여부:", - "퀴즈 데이터:": "퀴즈 데이터:", - "퀴즈 레벨:": "퀴즈 레벨:", "퀴즈 만들기": "퀴즈 만들기", - "퀴즈 배열 길이:": "퀴즈 배열 길이:", "퀴즈 보관 정책": "퀴즈 보관 정책", - "퀴즈 생성 옵션": "퀴즈 생성 옵션", "퀴즈 풀기": "퀴즈 풀기", "퀴즈를 만들어서 문제를 풀어보세요!": "퀴즈를 만들어서 문제를 풀어보세요!", - "텍스트가 선택되지 않는 PDF는 OCR 변환이 필요합니다!": "텍스트가 선택되지 않는 PDF는 OCR 변환이 필요합니다!", - "특정 페이지를 지정하고 싶으신가요?": "특정 페이지를 지정하고 싶으신가요?", + "크기 제한": "크기 제한", "틀린 문제 중심 재학습 가능": "틀린 문제 중심 재학습 가능", "팁: ": "팁: ", - "파일 page 제한: 선택했을 때 100page 이하": "파일 page 제한: 선택했을 때 100page 이하", "파일 링크가 만료되었습니다.": "파일 링크가 만료되었습니다.", + "파일 변환이 지연되고 있어요. 잠시 후 다시 시도해주세요.": "파일 변환이 지연되고 있어요. 잠시 후 다시 시도해주세요.", "파일 선택하기": "파일 선택하기", + "파일 업로드 실패:": "파일 업로드 실패:", + "파일 업로드 중 오류가 발생했습니다. 다시 시도해주세요.": "파일 업로드 중 오류가 발생했습니다. 다시 시도해주세요.", "파일 업로드 중...": "파일 업로드 중...", - "파일 크기 제한:": "파일 크기 제한:", "파일 크기에 따라 시간이 소요될 수 있습니다": "파일 크기에 따라 시간이 소요될 수 있습니다", - "파일명:": "파일명:", + "파일은 상업적 목적, AI 학습 목적으로 사용되지 않습니다.": "파일은 상업적 목적, AI 학습 목적으로 사용되지 않습니다.", "파일을 PDF로 변환하고 있어요": "파일을 PDF로 변환하고 있어요", "파일을 드래그하거나 버튼 클릭": "파일을 드래그하거나 버튼 클릭", "파일을 먼저 업로드해주세요.": "파일을 먼저 업로드해주세요.", @@ -236,20 +257,18 @@ "파일이 존재하지 않습니다.": "파일이 존재하지 않습니다.", "페이지": "페이지", "페이지 범위: ": "페이지 범위: ", + "페이지 범위를 올바르게 입력해주세요.": "페이지 범위를 올바르게 입력해주세요.", + "페이지 정보를 불러오는 중입니다. 잠시만 기다려주세요.": "페이지 정보를 불러오는 중입니다. 잠시만 기다려주세요.", + "페이지를 선택해주세요.": "페이지를 선택해주세요.", "평균 점수": "평균 점수", - "평균 점수:": "평균 점수:", + "학습 자료 파일": "학습 자료 파일", "해설": "해설", "해설 데이터 로딩 실패:": "해설 데이터 로딩 실패:", - "해설 데이터 응답:": "해설 데이터 응답:", - "해설 데이터:": "해설 데이터:", "해설 보기": "해설 보기", - "해설 페이지로 전달할 state 데이터:": "해설 페이지로 전달할 state 데이터:", "해설을 불러오는데 실패했습니다. 문제가 삭제되었을 수 있습니다.": "해설을 불러오는데 실패했습니다. 문제가 삭제되었을 수 있습니다.", "해설이 없습니다.": "해설이 없습니다.", "현재 생성중입니다 조금만 더 기다려주세요!": "현재 생성중입니다 조금만 더 기다려주세요!", "현재는 pdf 파일만 지원합니다.": "현재는 pdf 파일만 지원합니다.", "홈으로": "홈으로", - "확인": "확인", - "빈칸 넣기": "빈칸 넣기", - "문제 생성결과": "문제 생성결과" + "확인": "확인" } \ No newline at end of file diff --git a/src/utils/analytics.js b/src/shared/lib/analytics.js similarity index 94% rename from src/utils/analytics.js rename to src/shared/lib/analytics.js index bdf70d3..cbb727f 100644 --- a/src/utils/analytics.js +++ b/src/shared/lib/analytics.js @@ -4,9 +4,6 @@ import ReactGA from "react-ga4"; export const initGA = (measurementId) => { if (import.meta.env.DEV) { console.group("🚀 Google Analytics 초기화"); - console.log("📍 측정 ID:", measurementId || "❌ 설정되지 않음"); - console.log("🛠️ 환경:", import.meta.env.DEV ? "개발" : "프로덕션"); - console.log("🔧 디버그 모드:", import.meta.env.DEV ? "활성화" : "비활성화"); console.groupEnd(); } @@ -17,7 +14,6 @@ export const initGA = (measurementId) => { }); if (import.meta.env.DEV) { - console.log("✅ Google Analytics 초기화 완료"); } } else { if (import.meta.env.DEV) { @@ -33,9 +29,6 @@ export const logPageView = (path, title) => { // 개발 환경에서는 콘솔에 페이지뷰 로그 출력 if (import.meta.env.DEV) { console.group(`📄 GA PageView: ${title}`); - console.log("🔗 Path:", path); - console.log("📝 Title:", title); - console.log("⏰ Timestamp:", new Date().toLocaleTimeString()); console.groupEnd(); } @@ -51,8 +44,6 @@ export const logEvent = (eventName, parameters = {}) => { // 개발 환경에서는 콘솔에 이벤트 로그 출력 if (import.meta.env.DEV) { console.group(`🔥 GA Event: ${eventName}`); - console.log("📊 Parameters:", parameters); - console.log("⏰ Timestamp:", new Date().toLocaleTimeString()); console.groupEnd(); } diff --git a/src/shared/lib/lastEndpointStorage.js b/src/shared/lib/lastEndpointStorage.js new file mode 100644 index 0000000..0f5b567 --- /dev/null +++ b/src/shared/lib/lastEndpointStorage.js @@ -0,0 +1,15 @@ +const STORAGE_KEY = "lastEndpoint"; + +export const readLastEndpoint = () => localStorage.getItem(STORAGE_KEY) || "/"; + +export const writeLastEndpoint = (value) => { + localStorage.setItem(STORAGE_KEY, value); + return value; +}; + +export const normalizeLastEndpoint = (value) => { + if (!value || value === "/login/redirect" || value.startsWith("/login")) { + return "/"; + } + return value; +}; diff --git a/src/shared/lib/quizHistoryStorage.js b/src/shared/lib/quizHistoryStorage.js new file mode 100644 index 0000000..09cd601 --- /dev/null +++ b/src/shared/lib/quizHistoryStorage.js @@ -0,0 +1,60 @@ +const STORAGE_KEY = "quizHistory"; + +export const readQuizHistory = () => { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); + } catch (error) { + return []; + } +}; + +export const writeQuizHistory = (history) => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(history)); + return history; +}; + +export const getLatestQuizRecord = () => readQuizHistory()[0] || null; + +export const upsertQuizHistoryRecord = (record, { max = 20 } = {}) => { + const history = readQuizHistory(); + const existingIndex = history.findIndex( + (item) => item.problemSetId === record.problemSetId + ); + if (existingIndex === -1) { + history.unshift(record); + } + + if (history.length > max) { + history.splice(max); + } + + return writeQuizHistory(history); +}; + +export const updateQuizHistoryRecord = (problemSetId, updates) => { + const history = readQuizHistory(); + const index = history.findIndex((item) => item.problemSetId === problemSetId); + if (index === -1) { + return history; + } + + history[index] = { + ...history[index], + ...updates, + }; + + return writeQuizHistory(history); +}; + +export const removeQuizHistoryRecord = (problemSetId) => { + const history = readQuizHistory(); + const nextHistory = history.filter( + (item) => item.problemSetId !== problemSetId + ); + return writeQuizHistory(nextHistory); +}; + +export const clearQuizHistory = () => { + localStorage.removeItem(STORAGE_KEY); + return []; +}; diff --git a/src/utils/timer.js b/src/shared/lib/timer.js similarity index 99% rename from src/utils/timer.js rename to src/shared/lib/timer.js index c5f9417..869de38 100644 --- a/src/utils/timer.js +++ b/src/shared/lib/timer.js @@ -67,4 +67,3 @@ export const formatTime = (milliseconds) => { }; export default Timer; - diff --git a/src/shared/lib/useClickOutside.js b/src/shared/lib/useClickOutside.js new file mode 100644 index 0000000..6f36951 --- /dev/null +++ b/src/shared/lib/useClickOutside.js @@ -0,0 +1,27 @@ +import { useEffect } from "react"; + +export const useClickOutside = ({ + containerId, + triggerId, + onOutsideClick, + isEnabled = true, +}) => { + useEffect(() => { + if (!isEnabled) return; + + const handler = (e) => { + const container = document.getElementById(containerId); + const trigger = triggerId ? document.getElementById(triggerId) : null; + if ( + container && + !container.contains(e.target) && + (!trigger || !trigger.contains(e.target)) + ) { + onOutsideClick?.(e); + } + }; + + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [containerId, triggerId, onOutsideClick, isEnabled]); +}; diff --git a/src/shared/ui/logo/index.css b/src/shared/ui/logo/index.css new file mode 100644 index 0000000..22ebce7 --- /dev/null +++ b/src/shared/ui/logo/index.css @@ -0,0 +1,34 @@ +.logo { + display: inline-flex; + justify-content: center; + align-items: center; + cursor: pointer; + user-select: none; +} + +.logo-icon { + width: 28px; + height: 28px; + margin-right: 8px; + margin-bottom: 5px; +} + +.logo-text { + font-size: 24px; + color: #6366f1; + font-weight: bold; + white-space: nowrap; +} + +@media (max-width: 480px) { + .logo-icon { + width: 22px; + height: 22px; + margin-right: 6px; + margin-bottom: 3px; + } + + .logo-text { + font-size: 18px; + } +} diff --git a/src/shared/ui/logo/index.js b/src/shared/ui/logo/index.js new file mode 100644 index 0000000..58bd36e --- /dev/null +++ b/src/shared/ui/logo/index.js @@ -0,0 +1 @@ +export { default } from "./index.jsx"; diff --git a/src/shared/ui/logo/index.jsx b/src/shared/ui/logo/index.jsx new file mode 100644 index 0000000..c887c82 --- /dev/null +++ b/src/shared/ui/logo/index.jsx @@ -0,0 +1,15 @@ +import React from "react"; +import "./index.css"; + +const Logo = ({ className = "" }) => { + const logoClassName = ["logo", className].filter(Boolean).join(" "); + + return ( + + Q-Asker + Q-Asker + + ); +}; + +export default Logo; diff --git a/src/widgets/header/index.css b/src/widgets/header/index.css new file mode 100644 index 0000000..34650af --- /dev/null +++ b/src/widgets/header/index.css @@ -0,0 +1,312 @@ +.header { + background: white; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + position: relative; +} + +.header-inner { + width: 70%; + margin: 0 auto; + padding: 16px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.logo-area { + display: flex; + align-items: center; +} + +.icon-button { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + margin-right: 12px; +} + +.logo-link { + text-decoration: none; + color: inherit; +} + +.auth-buttons .text-button { + margin-left: 0; +} + +.text-button { + background: none; + border: none; + color: #6366f1; + cursor: pointer; + text-decoration: none; +} + +.auth-buttons { + display: flex; + align-items: center; + width: auto; +} + +.auth-buttons .text-button { + display: block; + width: 100%; + white-space: nowrap; +} + +.nav-link-area { + display: flex; + align-items: center; + gap: 12px; +} + +.nav-link-wrapper { + position: relative; + display: inline-flex; + align-items: center; +} + +.nav-tooltip { + position: absolute; + left: 50%; + top: calc(100% + 6px); + transform: translateX(-50%); + background: #111827; + color: #ffffff; + padding: 6px 8px 6px 10px; + border-radius: 999px; + font-size: 12px; + white-space: nowrap; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15); + z-index: 2; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.nav-tooltip::before { + content: ""; + position: absolute; + top: -4px; + left: 50%; + transform: translateX(-50%); + border-width: 0 6px 6px 6px; + border-style: solid; + border-color: transparent transparent #111827 transparent; +} + +.nav-tooltip-close { + background: none; + border: none; + color: inherit; + font-size: 12px; + cursor: pointer; + padding: 2px 4px; + line-height: 1; +} + +.nav-tooltip-close:hover { + opacity: 0.8; +} + +.profile-area { + position: relative; +} + +.profile-button { + width: 36px; + height: 36px; + border-radius: 50%; + border: 1px solid #e0e7ff; + background: #eef2ff; + color: #4f46e5; + font-weight: 700; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.profile-button:hover { + background: #e0e7ff; +} + +.profile-dropdown { + position: absolute; + right: 0; + top: calc(100% + 8px); + background: white; + border: 1px solid #e5e7eb; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + border-radius: 12px; + padding: 12px; + min-width: 180px; + z-index: 1001; +} + +.profile-name { + display: block; + font-weight: 600; + color: #111827; + margin-bottom: 10px; +} + +.profile-logout { + width: 100%; + background: none; + border: none; + color: #6366f1; + cursor: pointer; + text-align: left; + padding: 6px 4px; +} + +.profile-logout:hover { + color: #4f46e5; +} + +/* 헤더 상단 링크는 한 줄 유지 */ +.nav-link-area .nav-link { + width: auto; + display: inline-flex; + align-items: center; + white-space: nowrap; + padding: 8px 12px; +} + +.emoji-label { + display: inline-flex; + align-items: center; + margin-right: 6px; +} + +/* 사이드바 */ +.sidebar { + position: fixed; + top: 0; + left: 0; + width: 256px; + height: 100%; + background: white; + box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1); + transform: translateX(-100%); + transition: transform 0.3s ease-in-out; + z-index: 1000; +} + +.sidebar.open { + transform: translateX(0); +} + +.sidebar-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; +} + +.sidebar nav { + width: 100%; +} + +.sidebar nav a { + display: block; + padding: 12px 16px; + color: #374151; + text-decoration: none; +} + +.sidebar nav a:hover { + background: #eef2ff; + color: #6366f1; +} + +/* 네비게이션 링크 버튼 스타일 */ +.nav-link { + display: block; + width: 100%; + padding: 12px 16px; + color: #374151; + text-decoration: none; + background: none; + border: none; + text-align: left; + cursor: pointer; + font-size: 16px; + transition: all 0.2s ease; + box-sizing: border-box; +} + +.nav-link:hover { + background: #eef2ff; + color: #6366f1; +} +.primary-button { + background: #6366f1; + color: white; + border: none; + padding: 8px 16px; + border-radius: 8px; + cursor: pointer; +} +.primary-button:hover { + background: #4f46e5; +} + +.language-selector { + display: flex; + align-items: center; + justify-content: space-between; +} + +.language-button { + background: none; + border: none; + cursor: pointer; + font-size: 16px; + color: #374151; +} + +@media (max-width: 768px) { + .header-inner { + width: auto; + padding: 10px; + } +} + +@media (max-width: 480px) { + .header-inner { + padding: 8px 10px; + gap: 8px; + } + + .icon-button { + font-size: 20px; + margin-right: 8px; + } + + .nav-link-area { + gap: 6px; + } + + .nav-link-area .nav-link { + padding: 6px 8px; + font-size: 14px; + } + + .auth-buttons .text-button { + font-size: 14px; + } + + .profile-button { + width: 32px; + height: 32px; + font-size: 14px; + } + + .nav-tooltip { + display: none; + } +} diff --git a/src/widgets/header/index.jsx b/src/widgets/header/index.jsx new file mode 100644 index 0000000..a84c1d5 --- /dev/null +++ b/src/widgets/header/index.jsx @@ -0,0 +1,187 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { useHeader } from "./model/useHeader"; +import { useClickOutside } from "#shared/lib/useClickOutside"; +import Logo from "#shared/ui/logo"; +import "./index.css"; + +const Header = ({ + isSidebarOpen, + toggleSidebar, + setIsSidebarOpen, + setShowHelp, +}) => { + const { + state: { t, isAuthenticated, user }, + actions: { + handleQuizManagement, + handleHelp, + handleLogout, + handleLanguageChange, + closeSidebar, + }, + } = useHeader({ setIsSidebarOpen, setShowHelp }); + const [isProfileOpen, setIsProfileOpen] = useState(false); + const [showNavTooltip, setShowNavTooltip] = useState(false); + + const displayName = useMemo(() => { + const name = + user?.nickname || user?.name || user?.username || user?.email || ""; + return name.trim() || t("사용자"); + }, [t, user]); + const profileInitial = useMemo( + () => displayName?.trim().slice(0, 1).toUpperCase() || "?", + [displayName], + ); + + useClickOutside({ + containerId: "profileDropdown", + triggerId: "profileButton", + onOutsideClick: () => setIsProfileOpen(false), + isEnabled: isProfileOpen, + }); + + useEffect(() => { + if (!isAuthenticated) { + try { + const today = new Date().toISOString().slice(0, 10); + const dismissedDate = localStorage.getItem( + "headerNavTooltipDismissedDate", + ); + setShowNavTooltip(dismissedDate !== today); + } catch (error) { + setShowNavTooltip(true); + } + return undefined; + } + setShowNavTooltip(false); + return undefined; + }, [isAuthenticated]); + + const handleNavTooltipClose = () => { + try { + const today = new Date().toISOString().slice(0, 10); + localStorage.setItem("headerNavTooltipDismissedDate", today); + } catch (error) { + // ignore storage errors + } + setShowNavTooltip(false); + }; + + return ( +
+
+
+ + + + +
+
+
+ + 📋 + {t("퀴즈 기록")} + + {!isAuthenticated && showNavTooltip && ( + + {t("로그인하고, 퀴즈기록을 저장해보세요")} + + + )} +
+
+ {isAuthenticated ? ( +
+ + {isProfileOpen && ( +
+ {displayName} + +
+ )} +
+ ) : ( + + 🔐 + {t("로그인")} + + )} +
+
+
+ +
+ ); +}; + +export default Header; diff --git a/src/widgets/header/model/useHeader.js b/src/widgets/header/model/useHeader.js new file mode 100644 index 0000000..b9f9887 --- /dev/null +++ b/src/widgets/header/model/useHeader.js @@ -0,0 +1,124 @@ +import { useMemo } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useTranslation, useLanguageSwitcher } from "i18nexus"; +import CustomToast from "#shared/toast"; +import { authService, useAuthStore } from "#entities/auth"; +import { useClickOutside } from "#shared/lib/useClickOutside"; + +const decodeBase64ToUtf8 = (value) => { + if (typeof value !== "string" || !value) return null; + const cleaned = value.replace(/\s+/g, ""); + if (!/^[A-Za-z0-9+/=_-]+$/.test(cleaned)) return null; + const normalized = cleaned.replace(/-/g, "+").replace(/_/g, "/"); + const padding = normalized.length % 4; + const padded = padding ? + normalized.padEnd(normalized.length + (4 - padding), "=") : + normalized; + try { + const binary = atob(padded); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + return new TextDecoder("utf-8").decode(bytes); + } catch (error) { + return null; + } +}; + +const decodeBase64Json = (value) => { + const decoded = decodeBase64ToUtf8(value); + if (!decoded) return null; + try { + return JSON.parse(decoded); + } catch (error) { + return null; + } +}; + +const extractNicknameFromToken = (token) => { + if (typeof token !== "string" || !token) return null; + const segments = token.split("."); + if (segments.length < 2) return null; + const payload = decodeBase64Json(segments[1]); + return payload?.nickname ?? null; +}; + +export const useHeader = ({ setIsSidebarOpen, setShowHelp }) => { + const { changeLanguage } = useLanguageSwitcher(); + const { t } = useTranslation(); + const location = useLocation(); + const navigate = useNavigate(); + const accessToken = useAuthStore((state) => state.accessToken); + const user = useAuthStore((state) => state.user); + const isAuthenticated = Boolean(accessToken); + const nicknameFromToken = useMemo( + () => extractNicknameFromToken(accessToken), + [accessToken] + ); + const resolvedUser = useMemo(() => { + if (!nicknameFromToken) return user; + return { ...(user || {}), nickname: nicknameFromToken }; + }, [nicknameFromToken, user]); + + useClickOutside({ + containerId: "sidebar", + triggerId: "menuButton", + onOutsideClick: () => setIsSidebarOpen(false) + }); + + const handleQuizManagement = () => { + setIsSidebarOpen(false); + }; + + const handleHelp = () => { + if (typeof setShowHelp !== "function") { + setIsSidebarOpen(false); + return; + } + + setIsSidebarOpen(false); + setShowHelp((prev) => { + if (!prev) { + setTimeout(() => { + const helpElement = document.getElementById("help-section"); + if (helpElement) { + helpElement.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }, 100); + } + return !prev; + }); + }; + + const handleLogout = async () => { + try { + await authService.logout(); + CustomToast.info(t("로그아웃되었습니다.")); + } catch (error) { + CustomToast.error(t("로그아웃에 실패했습니다.")); + } + }; + + const handleLanguageChange = (lang) => { + changeLanguage(lang); + if (location.pathname === "/" || location.pathname === "/ko" || location.pathname === "/en") { + const targetPath = lang === "en" ? "/en" : "/ko"; + if (location.pathname !== targetPath) { + navigate(targetPath, { replace: true }); + } + } + }; + + const closeSidebar = () => { + setIsSidebarOpen(false); + }; + + return { + state: { t, isAuthenticated, user: resolvedUser }, + actions: { + handleQuizManagement, + handleHelp, + handleLogout, + handleLanguageChange, + closeSidebar + } + }; +}; \ No newline at end of file diff --git a/src/components/help/index.css b/src/widgets/help/index.css similarity index 100% rename from src/components/help/index.css rename to src/widgets/help/index.css diff --git a/src/components/help/index.jsx b/src/widgets/help/index.jsx similarity index 76% rename from src/components/help/index.jsx rename to src/widgets/help/index.jsx index 5b7db23..03e3100 100644 --- a/src/components/help/index.jsx +++ b/src/widgets/help/index.jsx @@ -1,76 +1,24 @@ import { useTranslation } from "i18nexus"; -import React, { useEffect, useRef, useState } from "react"; -import { useLocation } from "react-router-dom"; -import { trackHelpEvents } from "../../utils/analytics"; +import React from "react"; +import { useHelp } from "./model/useHelp"; import "./index.css"; const Help = () => { const { t } = useTranslation(); - const location = useLocation(); - - const startTimeRef = useRef(Date.now()); - const scrollTrackingRef = useRef({ - 25: false, - 50: false, - 75: false, - 100: false, - }); - - useEffect(() => { - const urlParams = new URLSearchParams(location.search); - const source = - urlParams.get("source") || - (document.referrer.includes("/") ? "header" : "direct"); - trackHelpEvents.viewHelp(source); - }, [location]); - - useEffect(() => { - const handleScroll = () => { - const scrollTop = window.pageYOffset; - const docHeight = - document.documentElement.scrollHeight - window.innerHeight; - if (docHeight <= 0) return; - const scrollPercent = Math.round((scrollTop / docHeight) * 100); - - Object.keys(scrollTrackingRef.current).forEach((threshold) => { - if ( - scrollPercent >= parseInt(threshold) && - !scrollTrackingRef.current[threshold] - ) { - scrollTrackingRef.current[threshold] = true; - trackHelpEvents.trackScrollDepth(parseInt(threshold)); - } - }); - }; - window.addEventListener("scroll", handleScroll); - return () => window.removeEventListener("scroll", handleScroll); - }, []); - - // 페이지 떠날 때 체류 시간 추적 - useEffect(() => { - return () => { - const timeSpent = Math.round((Date.now() - startTimeRef.current) / 1000); - if (timeSpent > 5) { - trackHelpEvents.trackTimeSpent(timeSpent); - } - }; - }, []); - - // 섹션 호버 핸들러 - const handleSectionHover = (sectionName) => { - trackHelpEvents.interactWithSection(sectionName); - }; + const { + actions: { handleSectionHover }, + } = useHelp(); return (

- {t("Q-Asker: PDF/PPT 파일로 무료 AI 퀴즈 생성")} + {t("Q-Asker: PDF, PPT, Word로 무료 AI 퀴즈 생성")}

{t( - "가지고 계신 학습 자료(PDF, PPT)로 AI 퀴즈를 만드는 가장 쉬운 방법을 알려드립니다." + "가지고 계신 학습 자료로 AI 퀴즈를 만드는 가장 쉬운 방법을 알려드립니다.", )}

@@ -83,9 +31,7 @@ const Help = () => { itemScope itemType="https://schema.org/HowTo" > -

- {t("📝 PDF/PPT로 AI 퀴즈 만들기 6단계 가이드")} -

+

{t("📝 AI 퀴즈 만들기 6단계 가이드")}

{/* 각 단계별 내용은 기존과 유사하게 유지 */}
{
  • - 📄 {t("지원 형식: ")} PDF, PPT, PPTX - (PowerPoint) + 📄 {t("지원 형식: ")} + {t("학습 자료 파일")}
  • 📤 {t("업로드 ")} @@ -107,7 +53,7 @@ const Help = () => {
  • 💡 {t("팁: ")} {t( - "원하는 페이지를 지정하면 AI 퀴즈 생성시 더 좋은 퀴즈를 만들 수 있습니다." + "원하는 페이지를 지정하면 AI 퀴즈 생성시 더 좋은 퀴즈를 만들 수 있습니다.", )}
@@ -131,8 +77,8 @@ const Help = () => { {t("전체 또는 특정 페이지 지정")}
  • - 🎯 {t("난이도: ")} - {t("빈칸 채우기(Easy), OX(Normal), 객관식(Hard) 중 선택")} + 🎯 {t("문제 유형: ")} + {t("빈칸 채우기, OX, 객관식 중 선택")}
  • @@ -248,25 +194,23 @@ const Help = () => {

    {t("🔄 복습 퀴즈")}

    {t( - "PDF, PPT 공부 자료로 퀴즈를 만들어 보세요. 핵심 개념을 빠르게 암기하고 시험 대비에 효과적입니다." + "PDF, PPT, Word 공부 자료로 퀴즈를 만들어 보세요. 핵심 개념을 빠르게 암기하고 시험 대비에 효과적입니다.", )}

    -

    {t("📈 단계별 풀어보기")}

    +

    {t("📈 유형별 풀어보기")}

    {t( - "Webb's Dok 이론에 기반한 단계별 풀어보기 기능을 활용해 한 단계씩 순서대로 풀어보세요." + "빈칸, OX, 객관식 유형을 번갈아 풀어보며 개념 이해와 기억을 균형 있게 강화하세요.", )}

    - {t("1. Easy 단계를 통해 핵심 개념을 암기하세요.")} + {t("1. 빈칸 채우기로 핵심 개념을 정리하세요.")}

    - {t("2. Normal 단계를 통해 간단한 맥락에 적용하세요.")} + {t("2. OX로 빠르게 개념을 점검하세요.")}

    - {t( - "3. Hard 단계를 통해 깊은 추론을 요구하는 문제를 풀어보세요." - )} + {t("3. 객관식으로 개념을 응용해 보세요.")}

    @@ -286,22 +230,14 @@ const Help = () => {

    {t("📋 명확한 문제 생성 기준")}

    - {t( - "Webb's dok 이론에 기반한 문제 생성 기준을 통해 문제를 생성합니다." - )} + {t("문제 유형별 기준에 맞춰 퀴즈를 생성합니다.")}

    -

  • {t("Easy: 순수 암기나 단순 이해를 묻는 문제")}
  • -
  • - {t( - "Normal: 주어진 개념을 간단한 맥락에 적용하거나 비교·분석하게 하는 문제" - )} -
  • +
  • {t("빈칸: 핵심 개념을 정확히 기억하는지 확인")}
  • - {t( - "Hard: 한 단계 더 깊은 추론, 문제 해결, 자료 해석 등을 요구" - )} + {t("OX: 핵심 개념의 옳고 그름을 빠르게 점검하는 문제")}
  • +
  • {t("객관식: 개념을 비교·분석하고 적용하는 문제")}
  • {/*
    @@ -338,7 +274,7 @@ const Help = () => { >

    {t( - "네, PDF/PPT 기반 AI 퀴즈 생성은 현재 완전 무료입니다. 별도의 회원가입 없이 누구나 자유롭게 이용할 수 있습니다." + "네, PDF, PPT, Word 기반 AI 퀴즈 생성은 현재 완전 무료입니다. 별도의 회원가입 없이 누구나 자유롭게 이용할 수 있습니다.", )}

    @@ -359,7 +295,7 @@ const Help = () => { >

    {t( - "네. 업로드된 파일은 퀴즈 생성을 위해서만 일시적으로 사용되며, 24시간 뒤에 삭제됩니다." + "네. 업로드된 파일은 퀴즈 생성을 위해서만 일시적으로 사용되며, 24시간 뒤에 삭제됩니다.", )}

    @@ -380,7 +316,7 @@ const Help = () => { >

    {t( - "AI는 높은 정확도로 문서를 분석하지만, 100% 완벽하지 않을 수 있습니다. 생성된 문제는 학습 참고용이며, 중요한 정보는 반드시 원본과 교차 확인해주세요." + "AI는 높은 정확도로 문서를 분석하지만, 100% 완벽하지 않을 수 있습니다. 생성된 문제는 학습 참고용이며, 중요한 정보는 반드시 원본과 교차 확인해주세요.", )}

    @@ -392,7 +328,7 @@ const Help = () => { itemType="https://schema.org/Question" >

    - {t("Q. 이미지로 된 PDF 파일도 퀴즈로 만들 수 있나요?")} + {t("Q. 이미지로 된 파일도 퀴즈로 만들 수 있나요?")}

    { >

    {t( - "아니요. 현재는 텍스트 선택이 가능한 '텍스트 기반'의 PDF, PPT, PPTX 파일만 지원합니다. 스캔 본이나 사진 형태의 문서는 분석이 어렵습니다." + "네. OCR을 지원하여 스캔 본이나 사진 형태의 문서도 분석할 수 있습니다.", )}

    @@ -420,13 +356,13 @@ const Help = () => {
  • {t("AI 한계점:")} {t( - "생성된 문제는 학습 참고용이며, 사실관계가 100% 정확하지 않을 수 있습니다. 중요한 정보는 반드시 원본과 교차 확인하세요." + "생성된 문제는 학습 참고용이며, 사실관계가 100% 정확하지 않을 수 있습니다. 중요한 정보는 반드시 원본과 교차 확인하세요.", )}
  • {t("기록 삭제:")} {t( - "삭제된 퀴즈 기록은 복구할 수 없으니 신중하게 결정해주세요." + "삭제된 퀴즈 기록은 복구할 수 없으니 신중하게 결정해주세요.", )}
  • @@ -441,7 +377,7 @@ const Help = () => {

    {t("📞 문의 및 피드백")}

    {t( - "Q-Asker 사용 중 궁금한 점이나 개선 아이디어가 있으시면 언제든지 알려주세요! 더 좋은" + "Q-Asker 사용 중 궁금한 점이나 개선 아이디어가 있으시면 언제든지 알려주세요! 더 좋은", )} {t("AI 퀴즈 생성")} diff --git a/src/widgets/help/model/useHelp.js b/src/widgets/help/model/useHelp.js new file mode 100644 index 0000000..303558d --- /dev/null +++ b/src/widgets/help/model/useHelp.js @@ -0,0 +1,62 @@ +import { useEffect, useRef } from "react"; +import { useLocation } from "react-router-dom"; +import { trackHelpEvents } from "#shared/lib/analytics"; + +export const useHelp = () => { + const location = useLocation(); + const startTimeRef = useRef(Date.now()); + const scrollTrackingRef = useRef({ + 25: false, + 50: false, + 75: false, + 100: false, + }); + + useEffect(() => { + const urlParams = new URLSearchParams(location.search); + const source = + urlParams.get("source") || + (document.referrer.includes("/") ? "header" : "direct"); + trackHelpEvents.viewHelp(source); + }, [location]); + + useEffect(() => { + const handleScroll = () => { + const scrollTop = window.pageYOffset; + const docHeight = + document.documentElement.scrollHeight - window.innerHeight; + if (docHeight <= 0) return; + const scrollPercent = Math.round((scrollTop / docHeight) * 100); + + Object.keys(scrollTrackingRef.current).forEach((threshold) => { + if ( + scrollPercent >= parseInt(threshold) && + !scrollTrackingRef.current[threshold] + ) { + scrollTrackingRef.current[threshold] = true; + trackHelpEvents.trackScrollDepth(parseInt(threshold)); + } + }); + }; + window.addEventListener("scroll", handleScroll); + return () => window.removeEventListener("scroll", handleScroll); + }, []); + + useEffect(() => { + return () => { + const timeSpent = Math.round((Date.now() - startTimeRef.current) / 1000); + if (timeSpent > 5) { + trackHelpEvents.trackTimeSpent(timeSpent); + } + }; + }, []); + + const handleSectionHover = (sectionName) => { + trackHelpEvents.interactWithSection(sectionName); + }; + + return { + state: {}, + actions: { handleSectionHover }, + }; +}; diff --git a/src/pages/MakeQuiz/ui/RecentChanges/index.css b/src/widgets/recent-changes/index.css similarity index 100% rename from src/pages/MakeQuiz/ui/RecentChanges/index.css rename to src/widgets/recent-changes/index.css diff --git a/src/widgets/recent-changes/index.jsx b/src/widgets/recent-changes/index.jsx new file mode 100644 index 0000000..a10817e --- /dev/null +++ b/src/widgets/recent-changes/index.jsx @@ -0,0 +1,27 @@ +import { useTranslation } from "i18nexus"; +import { useRecentChanges } from "./model/useRecentChanges"; +import "./index.css"; + +const RecentChanges = () => { + const { t } = useTranslation(); + const { + state: { changes }, + actions: { formatDate }, + } = useRecentChanges(); + + return ( +

    +

    {t("최근 변경사항")}

    +
      + {changes.map((log, index) => ( +
    • + {formatDate(log.dateTime)} + {t(log.updateText)} +
    • + ))} +
    +
    + ); +}; + +export default RecentChanges; diff --git a/src/widgets/recent-changes/model/useRecentChanges.js b/src/widgets/recent-changes/model/useRecentChanges.js new file mode 100644 index 0000000..238630f --- /dev/null +++ b/src/widgets/recent-changes/model/useRecentChanges.js @@ -0,0 +1,38 @@ +import { useTranslation } from "i18nexus";import { useEffect, useState } from "react"; +import axiosInstance from "#shared/api"; + +const formatDate = (isoString) => { + const date = new Date(isoString); + return new Intl.DateTimeFormat("ko-KR", { + timeZone: "Asia/Seoul", + year: "numeric", + month: "2-digit", + day: "2-digit" + }). + format(date). + replace(/\. /g, "."). + replace(/\.$/, ""); +}; + +export const useRecentChanges = () => {const { t } = useTranslation(); + const [changes, setChanges] = useState([]); + + useEffect(() => { + const fetchUpdates = async () => { + try { + const res = await axiosInstance.get("/updateLog"); + const data = res.data; + setChanges(data.updateLogs || []); + } catch (err) { + console.error(t("변경사항 로드 실패:"), err); + } + }; + + fetchUpdates(); + }, []); + + return { + state: { changes }, + actions: { formatDate } + }; +}; \ No newline at end of file diff --git a/vite.config.js b/vite.config.js index 156a52d..2b3b8ca 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,13 +1,23 @@ // vite.config.ts import react from "@vitejs/plugin-react"; import process from "process"; +import { createRequire } from "module"; import { defineConfig, loadEnv } from "vite"; -export default defineConfig(({ mode }) => { +const require = createRequire(import.meta.url); +const vitePrerender = require("vite-plugin-prerender"); + +export default defineConfig(({ mode, command }) => { const proxyTarget = loadEnv("prod", process.cwd(), "").VITE_BASE_URL; + const isBuild = command === "build"; + const prerenderPlugin = isBuild + ? vitePrerender({ + routes: ["/", "/ko", "/en"], + }) + : null; return { - plugins: [react()], + plugins: [react(), prerenderPlugin].filter(Boolean), server: { proxy: { "/api": {