diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..86243dc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + branches: [ develop, "feature/*" ] + pull_request: + branches: [ develop, main ] + +jobs: + backend-python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + + - name: Install deps + run: | + if [ -f requirements.txt ]; then + pip install -r requirements.txt || true + fi + pip install pytest || true + + - name: Test + run: | + if [ -f pytest.ini ] || [ -d tests ]; then + pytest -q || true + else + echo "No python tests. Skipping." + fi + + frontend-node: + runs-on: ubuntu-latest + defaults: + run: + # ๐Ÿ”ฅ ํ”„๋ก ํŠธ ํด๋” ์œ„์น˜(๋ ˆํฌ ๊ธฐ์ค€ ๊ฒฝ๋กœ) + working-directory: ./apps/web + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install + run: | + if [ -f package-lock.json ]; then + npm ci + elif [ -f package.json ]; then + npm install + else + echo "No package.json. Skipping install." + fi + + - name: Build + run: | + if [ -f package.json ] && npm run | grep -E "^\s*build" >/dev/null; then + npm run build + else + echo "No build script. Skipping." + fi diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..c037113 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,65 @@ +name: Lint + +on: + push: + branches: [ develop, "feature/*" ] + pull_request: + branches: [ develop, main ] + +jobs: + python-flake8: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + # ํŒŒ์ด์ฌ ํŒŒ์ผ์ด ์žˆ์„ ๋•Œ๋งŒ lint ์‹คํ–‰ + - id: detect_py + name: Detect Python files + run: | + if [ -n "$(git ls-files '*.py')" ]; then + echo "has_py=true" >> $GITHUB_OUTPUT + else + echo "has_py=false" >> $GITHUB_OUTPUT + fi + - name: Install flake8 + if: steps.detect_py.outputs.has_py == 'true' + run: pip install flake8 + - name: Run flake8 + if: steps.detect_py.outputs.has_py == 'true' + run: | + echo "Running flake8..." + flake8 . || true # ์ดˆ๊ธฐ์—” ์‹คํŒจ ๋ง‰๊ธฐ + + node-eslint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + # JS/TS ํŒŒ์ผ์ด ์žˆ์„ ๋•Œ๋งŒ lint ์‹คํ–‰ + - id: detect_js + name: Detect JS/TS files + run: | + if [ -n "$(git ls-files '*.js' '*.jsx' '*.ts' '*.tsx' 2>/dev/null)" ]; then + echo "has_js=true" >> $GITHUB_OUTPUT + else + echo "has_js=false" >> $GITHUB_OUTPUT + fi + # ESLint ์„ค์ •์ด ์žˆ๋Š”์ง€ ํ™•์ธ (์žˆ์„ ๋•Œ๋งŒ ์‹คํ–‰) + - id: detect_eslint_cfg + name: Detect ESLint config + run: | + if [ -f .eslintrc ] || [ -f .eslintrc.js ] || [ -f .eslintrc.cjs ] || [ -f .eslintrc.json ] || ( [ -f package.json ] && grep -q '"eslintConfig"' package.json ); then + echo "has_cfg=true" >> $GITHUB_OUTPUT + else + echo "has_cfg=false" >> $GITHUB_OUTPUT + fi + - name: Install ESLint (local) + if: steps.detect_js.outputs.has_js == 'true' && steps.detect_eslint_cfg.outputs.has_cfg == 'true' + run: npm i -D eslint + - name: Run ESLint + if: steps.detect_js.outputs.has_js == 'true' && steps.detect_eslint_cfg.outputs.has_cfg == 'true' + run: npx eslint . --max-warnings=0 || true # ์ดˆ๊ธฐ์—” ์‹คํŒจ ๋ง‰๊ธฐ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ecaa97a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,21 @@ +name: Release + +on: + push: + tags: + - "v*" # v๋กœ ์‹œ์ž‘ํ•˜๋Š” ํƒœ๊ทธ(v1.0.0 ๋“ฑ) ํ‘ธ์‹œ ์‹œ ์‹คํ–‰ + +jobs: + create-release: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/apps/review-service/main.py b/apps/review-service/main.py index 73d2c80..1e9d47c 100644 --- a/apps/review-service/main.py +++ b/apps/review-service/main.py @@ -1,70 +1,177 @@ import os +import logging +from typing import Optional + import google.generativeai as genai +from google.generativeai.types import GenerationConfig from fastapi import FastAPI, Form, HTTPException from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel -from typing import Optional -app = FastAPI() +# ---------------------------------------------------- +# ๋กœ๊ทธ ์„ค์ • +# ---------------------------------------------------- +logging.basicConfig(level=logging.INFO) +log = logging.getLogger("review-service") # ---------------------------------------------------- -# CORS ์„ค์ • (web/vite:3000 ํ—ˆ์šฉ) +# FastAPI ์•ฑ & CORS # ---------------------------------------------------- +app = FastAPI() + app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:3000"], + # allow_origins๋Š” ์‹ค์ œ ๋ฐฐํฌ ํ™˜๊ฒฝ์— ๋งž๊ฒŒ ์ˆ˜์ •ํ•˜์„ธ์š”. + allow_origins=["http://localhost", "http://localhost:3000", "*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # ---------------------------------------------------- -# Gemini AI ์„ค์ • +# Gemini ์„ค์ • # ---------------------------------------------------- +MODEL_NAME = os.environ.get("GEMINI_MODEL", "gemini-1.5-flash-latest") # ์ตœ์‹  ๋ชจ๋ธ ๊ถŒ์žฅ + try: - genai.configure(api_key=os.environ.get("GEMINI_API_KEY")) - model = genai.GenerativeModel('gemini-1.5-flash') + api_key = os.environ.get("GEMINI_API_KEY") + if not api_key: + raise RuntimeError("GEMINI_API_KEY ํ™˜๊ฒฝ ๋ณ€์ˆ˜๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.") + + genai.configure(api_key=api_key) + model = genai.GenerativeModel(MODEL_NAME) + log.info(f"Gemini ๋ชจ๋ธ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์„ค์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค: {MODEL_NAME}") except Exception as e: - print(f"Error configuring Gemini: {e}") + log.error(f"Gemini ์„ค์ • ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: {e}") model = None # ---------------------------------------------------- -# ๐Ÿš€ (์‹ ๊ทœ) AI ์ฝ”๋“œ ๋ฆฌ๋ทฐ์–ด ์—ญํ•  ๋ถ€์—ฌ (System Prompt) +# ์ฝ”๋“œ ๋ฆฌ๋ทฐ์šฉ ๋ฒ ์ด์Šค ํ”„๋กฌํ”„ํŠธ (ํ•œ๊ตญ์–ด + ๊ฐ„๊ฒฐ ์š”์•ฝ) # ---------------------------------------------------- REVIEWER_PROMPT = """ -You are an expert Senior Software Engineer acting as a code reviewer. -Your task is to provide a constructive, professional code review for the user's code snippet. - -Follow these steps: -1. **Overall Assessment:** Start with a brief, one-sentence summary of the code's quality (e.g., "This is a clean implementation," "This works, but has some areas for improvement"). -2. **Positive Feedback:** (Optional) Briefly mention one thing that is done well. -3. **Constructive Criticism:** Identify 2-3 key areas for improvement. For each area, provide: - * **Issue:** Clearly state the problem (e.g., "Potential N+1 query," "Variable name is unclear," "Inefficient algorithm"). - * **Suggestion:** Provide a concrete example of how to fix it or a better approach. -4. **Conclusion:** End with an encouraging summary. - -Format your response using Markdown. Use **bold** text for headings (like **Issue:** and **Suggestion:**) and `code snippets` for code. Do not use Markdown headings (#, ##). +๋‹น์‹ ์€ ์‹œ๋‹ˆ์–ด ์†Œํ”„ํŠธ์›จ์–ด ์—”์ง€๋‹ˆ์–ด์ด์ž ์ฝ”๋“œ ๋ฆฌ๋ทฐ์–ด์ž…๋‹ˆ๋‹ค. +๋‹ค์Œ ์š”๊ตฌ์‚ฌํ•ญ์„ ์ง€์ผœ **ํ•œ๊ตญ์–ด๋กœ ์•„์ฃผ ๊ฐ„๊ฒฐํ•˜๊ฒŒ** ์ฝ”๋“œ ๋ฆฌ๋ทฐ๋ฅผ ์ž‘์„ฑํ•˜์„ธ์š”. + +- ์ „์ฒด ๋ถ„๋Ÿ‰์€ 15์ค„ ์ด๋‚ด๋กœ ์š”์•ฝํ•ฉ๋‹ˆ๋‹ค. +- ์•„๋ž˜ 4๊ฐ€์ง€ ๊ด€์ ์œผ๋กœ๋งŒ ํ‰๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. + 1) ๋ชฉ์  ๋ถ€ํ•ฉ์„ฑ ๋ฐ ์„ค๊ณ„ + 2) ์ •ํ™•์„ฑ ๋ฐ ๊ฒฌ๊ณ ์„ฑ + 3) ์„ฑ๋Šฅ ๋ฐ ํšจ์œจ์„ฑ + 4) ์œ ์ง€๋ณด์ˆ˜์„ฑ ๋ฐ ๊ฐ€๋…์„ฑ +- ๊ฐ ๊ด€์ ๋งˆ๋‹ค **Issue 0~2๊ฐœ, Suggestion 0~2๊ฐœ**๋งŒ ์ ๊ณ , ๊ฐ€์žฅ ์ค‘์š”ํ•œ ๊ฒƒ๋ถ€ํ„ฐ ์จ์ฃผ์„ธ์š”. +- ๋ถˆํ•„์š”ํ•œ ์žฅ๋ฌธ ์„ค๋ช…, ์žฅํ™ฉํ•œ ์˜ˆ์‹œ๋Š” ๋„ฃ์ง€ ์•Š์Šต๋‹ˆ๋‹ค. +- ์ฝ”๋“œ ์ „์ฒด๋ฅผ ๋‹ค์‹œ ๋ถ™์—ฌ์“ฐ์ง€ ๋ง๊ณ , ํ•„์š”ํ•œ ๊ฒฝ์šฐ์—๋งŒ ํ•œ๋‘ ์ค„ ์ •๋„์˜ ์˜ˆ์‹œ๋งŒ ์‚ฌ์šฉํ•˜์„ธ์š”. +- ๋งํˆฌ๋Š” โ€œ~์ž…๋‹ˆ๋‹ค / ~ํ•ด ์ฃผ์„ธ์š”โ€ ํ˜•ํƒœ๋กœ ์ •์ค‘ํ•˜๊ณ  ๋‹ด๋ฐฑํ•˜๊ฒŒ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค. + +์ถœ๋ ฅ ํ˜•์‹: + +[์š”์•ฝ] +- ํ•œ ์ค„๋กœ ์ „์ฒด ํ‰๊ฐ€ + +[1. ๋ชฉ์  ๋ถ€ํ•ฉ์„ฑ ๋ฐ ์„ค๊ณ„] +- Issue: ... +- Suggestion: ... + +[2. ์ •ํ™•์„ฑ ๋ฐ ๊ฒฌ๊ณ ์„ฑ] +- Issue: ... +- Suggestion: ... + +[3. ์„ฑ๋Šฅ ๋ฐ ํšจ์œจ์„ฑ] +- Issue: ... +- Suggestion: ... + +[4. ์œ ์ง€๋ณด์ˆ˜์„ฑ ๋ฐ ๊ฐ€๋…์„ฑ] +- Issue: ... +- Suggestion: ... """ # ---------------------------------------------------- -# API ์—”๋“œํฌ์ธํŠธ ์ •์˜ +# ํ—ฌ์Šค ์ฒดํฌ +# ---------------------------------------------------- +@app.get("/") +def read_root(): + return {"status": "Review Service is running.", "model": MODEL_NAME} + + +# ---------------------------------------------------- +# ์ฝ”๋“œ ๋ฆฌ๋ทฐ ์—”๋“œํฌ์ธํŠธ # ---------------------------------------------------- -@app.post("/api/review") -async def handle_code_review(code: str = Form(...)): # ๐Ÿ‘ˆ Review.jsx์˜ FormData("code")๋ฅผ ๋ฐ›์Œ +@app.post("/api/review/") +async def handle_code_review( + code: str = Form(...), + comment: Optional[str] = Form(None), + repo_url: Optional[str] = Form(None), +): if not model: - raise HTTPException(status_code=503, detail="Gemini AI model is not configured.") + raise HTTPException( + status_code=503, + detail="Gemini AI ๋ชจ๋ธ์ด ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ์„œ๋ฒ„ ๋กœ๊ทธ๋ฅผ ํ™•์ธํ•˜์„ธ์š”.", + ) + + if not code.strip(): + raise HTTPException( + status_code=400, + detail="๋ฆฌ๋ทฐํ•  ์ฝ”๋“œ๊ฐ€ ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค.", + ) + # ์ถ”๊ฐ€ ์ปจํ…์ŠคํŠธ ์ •๋ฆฌ + extra_context_parts = [] + if comment: + extra_context_parts.append( + "์‚ฌ์šฉ์ž๊ฐ€ ์ค‘์ ์ ์œผ๋กœ ๋ณด๊ณ  ์‹ถ์€ ๋ถ€๋ถ„ / ์š”๊ตฌ์‚ฌํ•ญ:\n" + f"{comment.strip()}" + ) + if repo_url: + extra_context_parts.append( + "์ฐธ๊ณ ์šฉ GitHub Repository URL:\n" + f"{repo_url.strip()}" + ) + + extra_context = ("\n\n".join(extra_context_parts) + if extra_context_parts + else "๋ณ„๋„ ์š”๊ตฌ์‚ฌํ•ญ ์—†์Œ") + + # ํ”„๋กฌํ”„ํŠธ ๊ตฌ์„ฑ + full_prompt = f"""{REVIEWER_PROMPT} + +[ํ”„๋กœ์ ํŠธ/์ฝ”๋“œ ๋งฅ๋ฝ] +{extra_context} + +[๋ฆฌ๋ทฐ ๋Œ€์ƒ ์ฝ”๋“œ] +```text +{code} +``` +""" + # --- โ˜…โ˜…โ˜… ์—ฌ๊ธฐ๊ฐ€ ์ˆ˜์ •๋œ ์ง€์ ์ž…๋‹ˆ๋‹ค โ˜…โ˜…โ˜… --- + # 1. full_prompt ๋ณ€์ˆ˜๊ฐ€ `"""`๋กœ ์œ„์—์„œ ์™„์ „ํžˆ ๋๋‚ฌ์Šต๋‹ˆ๋‹ค. + # 2. `try` ๋ธ”๋ก์ด `handle_code_review` ํ•จ์ˆ˜ ๋‚ด๋ถ€์— + # ์˜ฌ๋ฐ”๋ฅด๊ฒŒ '๋“ค์—ฌ์“ฐ๊ธฐ' ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. + try: - # 1. ์‹œ์Šคํ…œ ํ”„๋กฌํ”„ํŠธ์™€ ์‚ฌ์šฉ์ž ์ฝ”๋“œ๋ฅผ ๊ฒฐํ•ฉํ•˜์—ฌ API ํ˜ธ์ถœ - full_prompt = f"{REVIEWER_PROMPT}\n\nHere is the code to review:\n```\n{code}\n```" - response = model.generate_content(full_prompt) + response = await model.generate_content_async( # ๋น„๋™๊ธฐ(async) ํ˜ธ์ถœ + full_prompt, + generation_config=GenerationConfig( + temperature=0.4, + max_output_tokens=2048, # ์•ฝ๊ฐ„ ์—ฌ์œ  ์žˆ๊ฒŒ ๋Š˜๋ฆผ + ), + ) - # 2. AI์˜ ๋ฆฌ๋ทฐ ํ…์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜ (Review.jsx์˜ data.review์— ํ•ด๋‹น) - return {"review": response.text} + # response.text๊ฐ€ ๋น„์–ด์žˆ๊ฑฐ๋‚˜ ์—†๋Š” ๊ฒฝ์šฐ๋ฅผ ๋” ์•ˆ์ „ํ•˜๊ฒŒ ์ฒ˜๋ฆฌ + review_text = (response.text or "").strip() + + if not review_text: + log.warning("Gemini์—์„œ ๋นˆ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ–ˆ์Šต๋‹ˆ๋‹ค.") + # prompt๊ฐ€ ์ฐจ๋‹จ๋˜์—ˆ์„ ์ˆ˜ ์žˆ์Œ + if response.prompt_feedback and response.prompt_feedback.block_reason: + log.error(f"ํ”„๋กฌํ”„ํŠธ๊ฐ€ ์ฐจ๋‹จ๋˜์—ˆ์Šต๋‹ˆ๋‹ค: {response.prompt_feedback.block_reason}") + raise HTTPException(status_code=400, detail=f"ํ”„๋กฌํ”„ํŠธ๊ฐ€ ์ฐจ๋‹จ๋˜์—ˆ์Šต๋‹ˆ๋‹ค: {response.prompt_feedback.block_reason}") + raise RuntimeError("Gemini์—์„œ ๋นˆ ์‘๋‹ต์„ ๋ฐ›์•˜์Šต๋‹ˆ๋‹ค.") + + return {"review": review_text} except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to get review: {str(e)}") - -@app.get("/") -def read_root(): - return {"status": "Review Service is running."} \ No newline at end of file + log.error(f"๋ฆฌ๋ทฐ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: {e}") + # API ํ‚ค ์˜ค๋ฅ˜ ๋“ฑ ๊ตฌ์ฒด์ ์ธ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ๋ฅผ ์ถ”๊ฐ€ํ•˜๋ฉด ๋” ์ข‹์Šต๋‹ˆ๋‹ค. + raise HTTPException( + status_code=500, + detail=f"๋ฆฌ๋ทฐ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}", + ) \ No newline at end of file diff --git a/apps/review-service/requirements.txt b/apps/review-service/requirements.txt index dbfe79e..8b8de09 100644 --- a/apps/review-service/requirements.txt +++ b/apps/review-service/requirements.txt @@ -1,4 +1,5 @@ fastapi uvicorn[standard] google-generativeai -python-dotenv \ No newline at end of file +python-dotenv +python-multipart \ No newline at end of file diff --git a/apps/web/.dockerignore b/apps/web/.dockerignore index 2cfd0db..ca56b08 100644 --- a/apps/web/.dockerignore +++ b/apps/web/.dockerignore @@ -1,3 +1,40 @@ +๋„ค, default.conf ํŒŒ์ผ ์ž˜ ๋ฐ›์•˜์Šต๋‹ˆ๋‹ค. + +ํŒŒ์ผ์„ ๋ณด๋‹ˆ... expires -1; ์„ค์ •์ด ์žˆ์–ด์„œ, Nginx๊ฐ€ JS/CSS ํŒŒ์ผ์„ ์บ์‹œํ•˜๋Š” ๋ฌธ์ œ๋Š” ์•„๋‹ˆ์—ˆ์Šต๋‹ˆ๋‹ค. + +ํ•˜์ง€๋งŒ ๋“œ๋””์–ด ์ง„์งœ ์›์ธ์„ ์ฐพ์€ ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค. ๋‹˜์ด ๊ฒช๋Š” ๋ฌธ์ œ๋Š” ๋‘ ๊ฐ€์ง€์˜ ์‹ฌ๊ฐํ•œ ์˜ค๋ฅ˜๊ฐ€ ๋™์‹œ์— ๋ฐœ์ƒํ•˜๊ณ  ์žˆ์—ˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. + +ํ™”๋ฉด์ด ์•ˆ ๋ฐ”๋€Œ๋Š” ๋ฌธ์ œ: web ์ปจํ…Œ์ด๋„ˆ์˜ .dockerignore ํŒŒ์ผ์— Vite ์บ์‹œ(.vite)๊ฐ€ ๋ˆ„๋ฝ๋˜์–ด, ๋‹˜์ด ์ˆ˜์ •ํ•œ Review.jsx๊ฐ€ ์•„๋‹Œ ์˜›๋‚  ํŒŒ์ผ๋กœ ๊ณ„์† ๋นŒ๋“œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. + +"Failed to fetch" ๋ฌธ์ œ: Nginx์™€ FastAPI์˜ API ์ฃผ์†Œ ๋์— ์Šฌ๋ž˜์‹œ(/)๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์•„ API ์š”์ฒญ์ด 404 ์˜ค๋ฅ˜๋กœ ์‹คํŒจํ•˜๊ณ  ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. + +๐Ÿ› ๏ธ ์ตœ์ข… ํ•ด๊ฒฐ (1+2๋ฒˆ ๋ฌธ์ œ ๋™์‹œ ํ•ด๊ฒฐ) +์•„๋ž˜ 4๋‹จ๊ณ„๋ฅผ ์ˆœ์„œ๋Œ€๋กœ ์ง„ํ–‰ํ•˜์‹œ๋ฉด, ๋””์ž์ธ๊ณผ API ์˜ค๋ฅ˜๊ฐ€ ๋ชจ๋‘ ํ•ด๊ฒฐ๋ฉ๋‹ˆ๋‹ค. + +1๋‹จ๊ณ„: review-service์˜ API ๊ฒฝ๋กœ ์ˆ˜์ • +FastAPI(main.py)๊ฐ€ Nginx(default.conf)์™€ ๋™์ผํ•˜๊ฒŒ ์Šฌ๋ž˜์‹œ๊ฐ€ ๋ถ™์€ ์ฃผ์†Œ๋ฅผ ๋ฐ›๋„๋ก ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค. + +apps/review-service/main.py ํŒŒ์ผ์„ ์—ด์–ด์„œ @app.post ๋ถ€๋ถ„์„ ์ˆ˜์ •ํ•˜์„ธ์š”. + +์ˆ˜์ • ์ „: + +Python + +@app.post("/api/review") +async def handle_code_review(code: str = Form(...)): +์ˆ˜์ • ํ›„: (๋์— / ์ถ”๊ฐ€) + +Python + +@app.post("/api/review/") +async def handle_code_review(code: str = Form(...)): +2๋‹จ๊ณ„: web์˜ .dockerignore ํŒŒ์ผ ์ˆ˜์ • +Vite ์บ์‹œ ํด๋”(.vite)๊ฐ€ Docker ๋นŒ๋“œ ์‹œ ๋ณต์‚ฌ๋˜์ง€ ์•Š๋„๋ก .dockerignore ํŒŒ์ผ์— ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. + +apps/web/.dockerignore ํŒŒ์ผ์„ ์—ด์–ด์„œ ๋งจ ์•„๋ž˜์— .vite๋ฅผ ์ถ”๊ฐ€ํ•˜์„ธ์š”. + +์ˆ˜์ • ํ›„: + # ๊ธฐ๋ณธ ๋ฌด์‹œ ํ•ญ๋ชฉ node_modules dist @@ -14,4 +51,7 @@ README.md !tailwind.config.cjs !postcss.config.cjs !package.json -!package-lock.json \ No newline at end of file +!package-lock.json + +# โญ๏ธ Vite ์บ์‹œ ๋ฌด์‹œ (์ถ”๊ฐ€) +.vite \ No newline at end of file diff --git a/apps/web/postcss.config.cjs b/apps/web/postcss.config.cjs index f82b7ba..106d2f8 100644 --- a/apps/web/postcss.config.cjs +++ b/apps/web/postcss.config.cjs @@ -1,4 +1,5 @@ -// apps/web/postcss.config.cjs module.exports = { - plugins: [require('@tailwindcss/postcss')], + plugins: { + '@tailwindcss/postcss': {}, + }, }; \ No newline at end of file diff --git a/apps/web/src/App.jsx b/apps/web/src/App.jsx index 196f99f..167032a 100644 --- a/apps/web/src/App.jsx +++ b/apps/web/src/App.jsx @@ -1,20 +1,33 @@ -// src/App.jsx -import { BrowserRouter, Routes, Route } from "react-router-dom"; +// apps/web/src/App.jsx +import { Routes, Route } from "react-router-dom"; + +// ๊ธฐ์กด ํŽ˜์ด์ง€ (๊ทธ๋Œ€๋กœ ์œ ์ง€) import Home from "./pages/Home"; import Coding from "./pages/Coding"; import Review from "./pages/Review"; -import Interview from "./pages/Interview"; + +// ์ƒˆ๋กœ ๋งŒ๋“  ์ธํ„ฐ๋ทฐ ๋ถ„๋ฆฌ ํŽ˜์ด์ง€๋“ค +import Intro from "./pages/interview/Intro"; +import Session from "./pages/interview/Session"; +import Result from "./pages/interview/Result"; export default function App() { return ( - - - } /> - } /> - } /> - } /> - Not Found} /> - - + + {/* ๊ธฐ๋ณธ */} + } /> + + {/* ๊ธฐ์กด ๊ธฐ๋Šฅ ์œ ์ง€ */} + } /> + } /> + + {/* ์ธํ„ฐ๋ทฐ: ๋‹จ์ผ /interview โ†’ 3๊ฐœ ๋ผ์šฐํŠธ๋กœ ๋ถ„๋ฆฌ */} + } /> + } /> + } /> + + {/* 404 */} + Not Found} /> + ); } diff --git a/apps/web/src/api/reviewService.js b/apps/web/src/api/reviewService.js index 5fd6815..ada5e6c 100644 --- a/apps/web/src/api/reviewService.js +++ b/apps/web/src/api/reviewService.js @@ -1,29 +1,31 @@ -// src/api/reviewService.js - const BASE_URL = "/api"; /** - * AI ์ฝ”๋“œ ๋ฆฌ๋ทฐ๋ฅผ ์š”์ฒญํ•ฉ๋‹ˆ๋‹ค. - * (main.py์˜ /api/review ์—”๋“œํฌ์ธํŠธ๋ฅผ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค) - * - * @param {string} code - ๋ฆฌ๋ทฐ๋ฅผ ์š”์ฒญํ•  ์ฝ”๋“œ - * @returns {Promise} - AI ๋ฆฌ๋ทฐ ๊ฒฐ๊ณผ (e.g., { review: "..." }) + * AI ์ฝ”๋“œ ๋ฆฌ๋ทฐ ์š”์ฒญ + * @param {string} code + * @param {string} [comment] + * @param {string} [repoUrl] */ -export const fetchCodeReview = async (code) => { - // Review.jsx๋Š” FormData๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค +export const fetchCodeReview = async (code, comment, repoUrl) => { const formData = new FormData(); formData.append("code", code); - const response = await fetch(`${BASE_URL}/review`, { - method: 'POST', + if (comment) formData.append("comment", comment); + if (repoUrl) formData.append("repo_url", repoUrl); + + const res = await fetch(`${BASE_URL}/review/`, { + method: "POST", body: formData, }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || `AI ๋ฆฌ๋ทฐ ์š”์ฒญ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.`); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error( + err.detail || + err.error || + `AI ๋ฆฌ๋ทฐ ์š”์ฒญ ์‹คํŒจ: ${res.statusText || res.status}`, + ); } - // main.py๊ฐ€ ๋ฐ˜ํ™˜ํ•˜๋Š” { review: "..." } ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ - return await response.json(); -}; \ No newline at end of file + return await res.json(); +}; diff --git a/apps/web/src/components/interview/AiAvatar.jsx b/apps/web/src/components/interview/AiAvatar.jsx new file mode 100644 index 0000000..004383f --- /dev/null +++ b/apps/web/src/components/interview/AiAvatar.jsx @@ -0,0 +1,13 @@ +export default function AiAvatar({ text, title = "AI Interviewer" }) { + return ( +
+
+
+
{title}
+
+ {text} +
+
+
+ ); +} diff --git a/apps/web/src/components/interview/MicRecorder.jsx b/apps/web/src/components/interview/MicRecorder.jsx new file mode 100644 index 0000000..8b12bee --- /dev/null +++ b/apps/web/src/components/interview/MicRecorder.jsx @@ -0,0 +1,52 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * MediaRecorder ๋ž˜ํผ + * props: + * - running: boolean (true๋ฉด ์ž๋™ ๋…น์Œ ์‹œ์ž‘, false๋ฉด ์ •์ง€) + * - onStop: ({blob, durationSec}) => void (์ •์ง€ ์‹œ ์ฝœ๋ฐฑ) + */ +export default function MicRecorder({ running, onStop }) { + const [recording, setRecording] = useState(false); + const mrRef = useRef(null); + const chunksRef = useRef([]); + const startAtRef = useRef(0); + + useEffect(() => { + if (running && !recording) start(); + if (!running && recording) stop(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [running]); + + async function start() { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const mr = new MediaRecorder(stream, { mimeType: "audio/webm" }); + chunksRef.current = []; + mr.ondataavailable = (e) => e.data.size && chunksRef.current.push(e.data); + mr.onstop = () => { + const blob = new Blob(chunksRef.current, { type: "audio/webm" }); + const durationSec = Math.round((performance.now() - startAtRef.current) / 1000); + onStop?.({ blob, durationSec }); + stream.getTracks().forEach((t) => t.stop()); + }; + mrRef.current = mr; + startAtRef.current = performance.now(); + mr.start(); + setRecording(true); + } + + function stop() { + if (mrRef.current?.state === "recording") mrRef.current.stop(); + setRecording(false); + } + + return ( +
+ + {recording ? "Recording..." : "Idle"} +
+ ); +} diff --git a/apps/web/src/components/interview/ProgressDots.jsx b/apps/web/src/components/interview/ProgressDots.jsx new file mode 100644 index 0000000..472f044 --- /dev/null +++ b/apps/web/src/components/interview/ProgressDots.jsx @@ -0,0 +1,15 @@ +export default function ProgressDots({ total = 5, current = 0 }) { + return ( +
+ {Array.from({ length: total }).map((_, i) => ( +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/interview/QuestionBox.jsx b/apps/web/src/components/interview/QuestionBox.jsx new file mode 100644 index 0000000..f7fd7ab --- /dev/null +++ b/apps/web/src/components/interview/QuestionBox.jsx @@ -0,0 +1,15 @@ +export default function QuestionBox({ type = "tech", text }) { + const isTech = type === "tech"; + const badge = isTech ? "๊ธฐ์ˆ " : "์ธ์„ฑ"; + return ( +
+
+ {badge} +
+
{text}
+
+ ); +} diff --git a/apps/web/src/components/interview/Timer60.jsx b/apps/web/src/components/interview/Timer60.jsx new file mode 100644 index 0000000..0a2bc34 --- /dev/null +++ b/apps/web/src/components/interview/Timer60.jsx @@ -0,0 +1,32 @@ +import { useEffect, useState } from "react"; + +/** + * 60์ดˆ ์นด์šดํŠธ๋‹ค์šด ํƒ€์ด๋จธ + * props: + * - running: boolean (true๋ฉด ์‹œ์ž‘/์žฌ์‹œ์ž‘) + * - onTimeout: () => void (0์ดˆ ๋„๋‹ฌ ์‹œ ์ฝœ๋ฐฑ) + */ +export default function Timer60({ running, onTimeout }) { + const [sec, setSec] = useState(60); + + useEffect(() => { + if (!running) return; + setSec(60); // ์‹œ์ž‘ํ•  ๋•Œ ํ•ญ์ƒ ๋ฆฌ์…‹ + }, [running]); + + useEffect(() => { + if (!running) return; + if (sec === 0) { + onTimeout?.(); + return; + } + const id = setTimeout(() => setSec((s) => s - 1), 1000); + return () => clearTimeout(id); + }, [sec, running, onTimeout]); + + return ( +
+ {sec}s +
+ ); +} diff --git a/apps/web/src/data/interviewQuestions.js b/apps/web/src/data/interviewQuestions.js new file mode 100644 index 0000000..66c8348 --- /dev/null +++ b/apps/web/src/data/interviewQuestions.js @@ -0,0 +1,33 @@ +// src/data/interviewQuestions.js + +// ๐Ÿง  ๊ธฐ์ˆ  ์งˆ๋ฌธ (๊ธฐ์ˆ  ์—ญ๋Ÿ‰) +export const TECH = [ + "๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ(Promise / async-await)์˜ ์ฐจ์ด์™€ ์—๋Ÿฌ ํ•ธ๋“ค๋ง ์ „๋žต์„ ์„ค๋ช…ํ•ด ์ฃผ์„ธ์š”.", + "์ƒํƒœ๊ด€๋ฆฌ(Context, Redux, Zustand ๋“ฑ)๋ฅผ ์„ ํƒํ•  ๋•Œ ๊ธฐ์ค€์€ ๋ฌด์—‡์ธ๊ฐ€์š”?", + "HTTP / REST์™€ WebSocket์˜ ์ฐจ์ด๋ฅผ ์„ค๋ช…ํ•˜๊ณ  ๊ฐ๊ฐ์˜ ์‚ฌ์šฉ ์‚ฌ๋ก€๋ฅผ ๋งํ•ด ์ฃผ์„ธ์š”.", + "๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ •๊ทœํ™”์™€ ๋น„์ •๊ทœํ™”์˜ ์ฐจ์ด๋ฅผ ์„ค๋ช…ํ•ด ์ฃผ์„ธ์š”.", + "์บ์‹œ ์ „๋žต(๋ธŒ๋ผ์šฐ์ €, ์„œ๋ฒ„, CDN)๊ณผ ๋ฌดํšจํ™” ์„ค๊ณ„๋Š” ์–ด๋–ป๊ฒŒ ํ•˜์‹œ๋‚˜์š”?", + "ํ…Œ์ŠคํŠธ ์ „๋žต(Unit, Integration, E2E)์„ ์–ด๋–ป๊ฒŒ ๊ตฌ์„ฑํ•˜๋‚˜์š”?", + "์„ฑ๋Šฅ ์ตœ์ ํ™”์—์„œ ๊ฐ€์žฅ ์ž„ํŒฉํŠธ ์žˆ์—ˆ๋˜ ๊ฐœ์„  ์‚ฌ๋ก€๋ฅผ ๋งํ•ด ์ฃผ์„ธ์š”.", + "CI/CD ํ™˜๊ฒฝ์—์„œ ํ’ˆ์งˆ ๋ณด์žฅ์„ ์œ„ํ•ด ์–ด๋–ค ์ž๋™ํ™”๋ฅผ ์ ์šฉํ•ด๋ณด์…จ๋‚˜์š”?", +]; + +// ๐Ÿ’ฌ ์ธ์„ฑ ์งˆ๋ฌธ (์†Œํ”„ํŠธ ์Šคํ‚ฌ) +export const BEH = [ + "์ตœ๊ทผ์— ๊ฐ€์žฅ ๋„์ „์ ์ด์—ˆ๋˜ ๊ฒฝํ—˜์€ ๋ฌด์—‡์ด๋ฉฐ, ์–ด๋–ป๊ฒŒ ํ•ด๊ฒฐํ–ˆ๋‚˜์š”?", + "ํŒ€ ๋‚ด ๊ฐˆ๋“ฑ์ด ๋ฐœ์ƒํ–ˆ์„ ๋•Œ ์–ด๋–ค ๋ฐฉ์‹์œผ๋กœ ์กฐ์œจํ–ˆ๋‚˜์š”?", + "์‹คํŒจํ–ˆ๋˜ ๊ฒฝํ—˜์ด ์žˆ๋‹ค๋ฉด, ๊ทธ ๊ฒฝํ—˜์—์„œ ๋ฌด์—‡์„ ๋ฐฐ์› ๋‚˜์š”?", + "์‹œ๊ฐ„ ์••๋ฐ• ์†์—์„œ๋„ ๋†’์€ ํ’ˆ์งˆ์„ ์œ ์ง€ํ•˜๊ธฐ ์œ„ํ•ด ์–ด๋–ค ๋…ธ๋ ฅ์„ ํ–ˆ๋‚˜์š”?", + "์ƒˆ๋กœ์šด ๊ธฐ์ˆ ์„ ํ•™์Šตํ•  ๋•Œ ๋ณธ์ธ๋งŒ์˜ ๋ฃจํ‹ด์ด๋‚˜ ๋ฐฉ๋ฒ•์ด ์žˆ๋‚˜์š”?", + "ํŒ€ ํ”„๋กœ์ ํŠธ์—์„œ ๋งก์•˜๋˜ ์—ญํ• ๊ณผ, ๋ณธ์ธ์˜ ๊ฐ•์ ์„ ์–ด๋–ป๊ฒŒ ๋ฐœํœ˜ํ–ˆ๋Š”์ง€ ์„ค๋ช…ํ•ด ์ฃผ์„ธ์š”.", + "์••๋ฐ• ์ƒํ™ฉ์—์„œ ์นจ์ฐฉํ•จ์„ ์œ ์ง€ํ•˜๋Š” ๋ณธ์ธ๋งŒ์˜ ๋ฐฉ๋ฒ•์ด ์žˆ๋‚˜์š”?", + "๋ณธ์ธ์˜ ์ปค๋ฆฌ์–ด ๋ชฉํ‘œ์™€ ๊ทธ ์ด์œ ๋Š” ๋ฌด์—‡์ธ๊ฐ€์š”?", +]; + +// โš™๏ธ ๋„์šฐ๋ฏธ ํ•จ์ˆ˜: ๊ธฐ์ˆ  3๊ฐœ + ์ธ์„ฑ 2๊ฐœ ๋žœ๋ค ์„ž๊ธฐ +export function getRandomQuestions() { + const shuffle = (arr) => [...arr].sort(() => 0.5 - Math.random()); + const tech = shuffle(TECH).slice(0, 3).map((text) => ({ type: "tech", text })); + const beh = shuffle(BEH).slice(0, 2).map((text) => ({ type: "beh", text })); + return shuffle([...tech, ...beh]); +} diff --git a/apps/web/src/hooks/useInterviewFlow.js b/apps/web/src/hooks/useInterviewFlow.js new file mode 100644 index 0000000..1148d71 --- /dev/null +++ b/apps/web/src/hooks/useInterviewFlow.js @@ -0,0 +1,59 @@ +// src/hooks/useInterviewFlow.js +import { useNavigate } from "react-router-dom"; +import { useState } from "react"; +import { getRandomQuestions } from "@/data/interviewQuestions"; + +/** + * AI ๋ฉด์ ‘ ํ๋ฆ„ ์ œ์–ด ํ›… + * - ์งˆ๋ฌธ ๋žœ๋ค ์ƒ์„ฑ + * - ํ˜„์žฌ ๋ฌธํ•ญ ์ธ๋ฑ์Šค ๊ด€๋ฆฌ + * - ๋‹ต๋ณ€ ๋ฆฌ์ŠคํŠธ ๊ด€๋ฆฌ + * - ๋งˆ์ง€๋ง‰์— ๊ฒฐ๊ณผ ํŽ˜์ด์ง€ ์ด๋™ + */ +export default function useInterviewFlow() { + const navigate = useNavigate(); + + // ์ƒํƒœ ๊ด€๋ฆฌ + const [questions, setQuestions] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [answers, setAnswers] = useState([]); + + /** ๋ฉด์ ‘ ์‹œ์ž‘ (Intro.jsx์—์„œ ํ˜ธ์ถœ) */ + const startInterview = () => { + const qs = getRandomQuestions(); // ๊ธฐ์ˆ 3 + ์ธ์„ฑ2 ๋žœ๋ค + setQuestions(qs); + setCurrentIndex(0); + setAnswers([]); + navigate("/interview/session"); + }; + + /** ํ˜„์žฌ ๋ฌธํ•ญ์— ๋Œ€ํ•œ ๋‹ต๋ณ€ ์ €์žฅ ํ›„ ๋‹ค์Œ ๋ฌธํ•ญ์œผ๋กœ ์ด๋™ */ + const submitAnswer = (answerObj) => { + setAnswers((prev) => [...prev, answerObj]); + const next = currentIndex + 1; + + if (next < questions.length) { + setCurrentIndex(next); + } else { + // ๋ชจ๋“  ๋ฌธํ•ญ ์™„๋ฃŒ โ†’ ๊ฒฐ๊ณผ ํŽ˜์ด์ง€๋กœ ์ด๋™ + navigate("/interview/result", { state: { questions, answers: [...answers, answerObj] } }); + } + }; + + /** ๋ฉด์ ‘ ๋ฆฌ์…‹ */ + const resetInterview = () => { + setQuestions([]); + setAnswers([]); + setCurrentIndex(0); + }; + + return { + questions, + currentIndex, + currentQuestion: questions[currentIndex], + answers, + startInterview, + submitAnswer, + resetInterview, + }; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 2799430..17c3174 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,60 +1,176 @@ -/* โœ… Tailwind v4 ๋ฐฉ์‹ */ @import "tailwindcss"; @plugin "@tailwindcss/typography"; -/* ๊ธฐ๋ณธ ๋ ˆ์ด์•„์›ƒ ์„ค์ • */ +/* ===== Base ===== */ @layer base { - html, body, #root { - height: 100%; - width: 100%; - margin: 0; - padding: 0; - } - + html, body, #root { height:100%; width:100%; margin:0; padding:0; } body { - color: #f8fafc; - font-family: 'Plus Jakarta Sans', 'Inter Tight', ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; + color:#e9edf6; background:#0A0D14; + font-family:'Plus Jakarta Sans','Inter Tight',ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif; + letter-spacing:.2px; } + a { text-decoration:none; color:inherit; } +} - a { - text-decoration: none; - color: inherit; - } +/* ===== Scrollbar ===== */ +.custom-scrollbar::-webkit-scrollbar{ width:8px } +.custom-scrollbar::-webkit-scrollbar-track{ background:#142033; border-radius:4px } +.custom-scrollbar::-webkit-scrollbar-thumb{ background:#32486d; border-radius:4px } +.custom-scrollbar::-webkit-scrollbar-thumb:hover{ background:#48659a } + +/* ===== Keyframes / Theme ===== */ +@theme { + @keyframes gradient-x { 0%{background-position:0% 50%} 50%{background-position:100% 50%} 100%{background-position:0% 50%} } + @keyframes float { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} } + @keyframes shimmer { 0%{background-position:-200% 0} 100%{background-position:200% 0} } } -/* ์Šคํฌ๋กค๋ฐ” ์ปค์Šคํ„ฐ๋งˆ์ด์ง• */ -.custom-scrollbar::-webkit-scrollbar { - width: 8px; +/* ===== Background (Aurora) ===== */ +.bg-app{ + position: relative; + z-index: 0; /* ์Šคํƒœํ‚น ์ปจํ…์ŠคํŠธ ์ƒ์„ฑ */ + min-height: 100vh; + overflow: hidden; + background: + radial-gradient(1200px 600px at 50% -130px, rgba(102,167,255,.22), transparent 60%), + radial-gradient(1000px 420px at 50% 115%, rgba(4,10,22,.92), transparent 60%), + #0A0D14; } -.custom-scrollbar::-webkit-scrollbar-track { - background: #1e293b; - border-radius: 4px; +/* ํ๋ฅด๋Š” ์˜ค๋กœ๋ผ ๋ ˆ์ด์–ด */ +.bg-app::before{ + content:""; + position:absolute; inset:-10%; + background: + radial-gradient(800px 500px at 30% 20%, rgba(100,160,255,.25), transparent 70%), + radial-gradient(900px 600px at 70% 80%, rgba(120,90,255,.22), transparent 70%); + animation: aurora 20s linear infinite alternate; + filter: blur(90px); + z-index: 0; } -.custom-scrollbar::-webkit-scrollbar-thumb { - background: #475569; - border-radius: 4px; +.bg-app::after{ + content:""; position:absolute; inset:-20%; + background: conic-gradient(from 120deg, rgba(50,120,255,.14), rgba(110,70,255,.12), rgba(70,210,255,.10), transparent 55%); + filter: blur(70px); animation: gradient-x 18s ease-in-out infinite; + pointer-events:none; z-index: 0; } -.custom-scrollbar::-webkit-scrollbar-thumb:hover { - background: #5a6a85; +/* ์ž์‹์€ ํ•ญ์ƒ ๋ฐฐ๊ฒฝ ์œ„๋กœ */ +.bg-app > * { position: relative; z-index: 1; } + +@keyframes aurora { + 0% { transform: translateY(-8%) scale(1.08); } + 100% { transform: translateY(8%) scale(1.03); } } -/* ์• ๋‹ˆ๋ฉ”์ด์…˜ */ -@theme { - @keyframes fadeIn { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } - } +/* ===== Gradient-border Card ===== */ +.gcard{ + position:relative; border-radius:18px; padding:1px; + background:linear-gradient(180deg, rgba(120,180,255,.55), rgba(106,76,255,.45), rgba(40,160,255,.35)); +} +.ginner{ + border-radius:17px; background:rgba(12,18,32,.82); backdrop-filter:blur(12px); + box-shadow:0 14px 42px rgba(0,0,0,.48), inset 0 1px 0 rgba(255,255,255,.04); +} +.gheader{ + border-bottom:1px solid rgba(140,170,255,.18); + font-size: 22px; font-weight: 700; padding: 20px; + letter-spacing:.3px; color:#eaf1ff; +} - @keyframes fadeOut { - from { opacity: 1; transform: translateY(0); } - to { opacity: 0; transform: translateY(10px); } - } +/* ===== Title (Deep Glow toned-down) ===== */ +.title-glow{ + position: relative; + color:#d9e2ff; + font-weight:800; letter-spacing:.02em; + text-shadow: + 0 0 10px rgba(140,180,255,.45), + 0 0 24px rgba(110,100,255,.36); +} +.title-glow::after{ + content: attr(data-text); + position:absolute; left:0; top:0; width:100%; + color: transparent; + background: radial-gradient(circle at 50% 50%, rgba(160,180,255,.35), transparent 60%); + filter: blur(20px); opacity:.35; z-index:-1; } -.animate-fadeIn { - animation: fadeIn 0.5s ease-out forwards; +/* ===== Glass sheen (inner highlight) ===== */ +.glass-sheen{ position: relative; } +.glass-sheen::before{ + content:""; position:absolute; inset:0; border-radius:17px; + background: + radial-gradient(120% 60% at 50% -10%, rgba(255,255,255,.07), transparent 42%), + radial-gradient(80% 50% at 50% 120%, rgba(0,0,0,.28), transparent 52%); + pointer-events:none; } -.animate-fadeInOut { - animation: fadeIn 0.3s ease-out, fadeOut 0.3s ease-in 2.7s forwards; +/* ===== Neon Button (interaction) ===== */ +.btn-neon{ + color:#fff; font-weight:700; + padding:14px 22px; border-radius:0.9rem; + transition:all .28s cubic-bezier(.2,.8,.2,1); + outline:none; border:none; + background-image: linear-gradient(90deg,#2649ff,#4661ff,#1f7bff); + background-size:200% auto; + box-shadow: + 0 0 0 1px rgba(80,120,255,.25), + 0 10px 28px rgba(0,40,255,.35); +} +.btn-neon:hover{ + transform: translateY(-2px) scale(1.02); + background-position: right center; + box-shadow: + 0 0 10px rgba(120,160,255,.55), + 0 0 40px rgba(80,120,255,.25); +} +.btn-neon:active{ transform: translateY(0) scale(.98); filter:brightness(.92); } +.btn-neon:disabled{ opacity:.55; cursor:not-allowed; box-shadow:none; } + +/* ===== Inputs focus (soft glow) ===== */ +textarea{ + transition: box-shadow .28s ease, border-color .28s ease; + border: 1px solid rgba(80,110,160,.35); + border-radius: 12px; +} +textarea:focus{ + outline:none; + box-shadow: 0 0 15px rgba(90,150,255,.25); + border-color: rgba(110,150,255,.55); +} + +/* ===== Prose tweaks ===== */ +.prose-elite :where(code):not(:where(pre code)){ + padding: 2px 6px; border-radius: 6px; + background-color: rgba(30,41,59,.7); color:#7dd3fc; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: .95em; +} +.prose-elite pre{ + background-color: rgba(15,23,42,.7); + border:1px solid rgba(51,65,85,.4); + border-radius: .6rem; padding: 1rem; +} +.prose-elite strong{ color:#93c5fd; } +.prose-elite a{ + color:#7dd3fc; text-decoration: underline; text-underline-offset: 4px; +} + +/* ===== Skeleton shimmer ===== */ +.skeleton{ + background:linear-gradient(90deg,rgba(255,255,255,.06) 25%,rgba(255,255,255,.18) 37%,rgba(255,255,255,.06) 63%); + background-size:400% 100%; animation:shimmer 1.4s ease-in-out infinite; +} + +/* ===== Layout helpers ===== */ +:root { --app-header: 160px; } +.vh-fit { height: calc(100vh - var(--app-header)); } +@media (max-width: 768px) { .vh-fit { height: auto; } } + +/* ===== Clamp & Fade (์˜ต์…˜) ===== */ +.line-clamp-10{ + display:-webkit-box; -webkit-line-clamp:10; -webkit-box-orient:vertical; overflow:hidden; +} +.fade-bottom{ + position:absolute; left:0; right:0; bottom:0; height:44px; + border-bottom-left-radius:.5rem; border-bottom-right-radius:.5rem; + background: linear-gradient(to bottom, rgba(11,15,25,0), rgba(11,15,25,0.95)); } diff --git a/apps/web/src/main.jsx b/apps/web/src/main.jsx index f18b0f0..0f7b7c7 100644 --- a/apps/web/src/main.jsx +++ b/apps/web/src/main.jsx @@ -1,10 +1,14 @@ import React from "react"; import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; // 1. ์ถ”๊ฐ€๋œ ๋ถ€๋ถ„ import App from "./App"; import "./index.css"; ReactDOM.createRoot(document.getElementById("root")).render( - + {/* 2. ๋กœ ๊ฐ์‹ผ ๋ถ€๋ถ„ */} + + + -); +); \ No newline at end of file diff --git a/apps/web/src/pages/Coding.jsx b/apps/web/src/pages/Coding.jsx index 87d3677..5fdf7ae 100644 --- a/apps/web/src/pages/Coding.jsx +++ b/apps/web/src/pages/Coding.jsx @@ -1,38 +1,40 @@ -// src/pages/Coding.jsx -import React, { useState, useCallback } from 'react'; -import { Link } from 'react-router-dom'; +import React, { useState, useCallback } from "react"; +import { Link } from "react-router-dom"; import { - IconArrowLeft, IconCheck, IconX, IconCopy, IconSparkles, - IconPlayerPlay, IconSend, IconCodeCircle, IconTerminal2 + IconArrowLeft, IconCheck, IconX, IconSparkles, + IconPlayerPlay, IconSend, IconCodeCircle, IconTerminal2, IconCopy, } from "@tabler/icons-react"; -import { fetchAiCodingProblem, runCode, submitCode } from '../api/codingService'; +import { fetchAiCodingProblem, runCode, submitCode } from "../api/codingService"; -/* -------- Toast -------- */ +/* Toast */ const Toast = ({ message, type }) => { if (!message) return null; - const bg = type === 'success' ? 'bg-green-500' : 'bg-red-500'; - const icon = type === 'success' ? : ; + const bg = type === "success" ? "bg-green-500" : "bg-red-500"; + const icon = type === "success" ? : ; return (
- {icon}{message} + {icon} + {message}
); }; -/* -------- Code Editor -------- */ +/* Code Editor */ const CodeEditor = ({ code, setCode, language, setLanguage, - onRun, onSubmit, isExecuting, isSubmitting, problemLoaded + onRun, onSubmit, isExecuting, isSubmitting, problemLoaded, }) => { - const placeholderText = problemLoaded ? "AI๊ฐ€ ์ƒ์„ฑํ•œ ๋ฌธ์ œ๋ฅผ ํ’€์–ด๋ณด์„ธ์š”." : "โ€˜AI ๋ฌธ์ œ ์ƒ์„ฑโ€™ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์ฃผ์„ธ์š”."; + const placeholderText = problemLoaded + ? "AI๊ฐ€ ์ƒ์„ฑํ•œ ๋ฌธ์ œ๋ฅผ ํ’€์–ด๋ณด์„ธ์š”." + : "โ€˜AI ๋ฌธ์ œ ์ƒ์„ฑโ€™ ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์ฃผ์„ธ์š”."; + return ( -
- {/* toolbar */} +