From 0025e39e2d9c52bae38a3405d53a494b7bef87e6 Mon Sep 17 00:00:00 2001 From: DevSecOps Date: Fri, 3 Jul 2026 13:28:19 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=20?= =?UTF-8?q?=E2=80=94=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BD=D0=B5=D0=B4=D0=BE=D1=81=D1=82=D0=B0=D1=8E=D1=89=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B,=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B8=20CI/CD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .drone.yml | 60 +++++++++++ README.md | 153 ++++++++++++++++++++-------- backend/.env.example | 11 +- backend/Dockerfile | 12 +++ backend/app/schemas/ai_match.py | 17 ++++ backend/app/schemas/auth.py | 8 ++ backend/app/schemas/escrow.py | 14 +++ backend/requirements.txt | 1 + backend/tests/__init__.py | 1 + backend/tests/conftest.py | 83 +++++++++++++++ backend/tests/test_health.py | 13 +++ docker-compose.yml | 38 ++++++- frontend/Dockerfile | 12 +++ frontend/app/ai-match/page.tsx | 86 ++++++++++++++++ frontend/app/auth/login/page.tsx | 73 +++++++++++++ frontend/app/projects/[id]/page.tsx | 66 ++++++++++++ frontend/components/ui/button.tsx | 51 ++++++++++ frontend/postcss.config.js | 6 ++ frontend/tailwind.config.js | 11 ++ frontend/tsconfig.json | 23 +++++ 20 files changed, 693 insertions(+), 46 deletions(-) create mode 100644 .drone.yml create mode 100644 backend/Dockerfile create mode 100644 backend/app/schemas/ai_match.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/schemas/escrow.py create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_health.py create mode 100644 frontend/Dockerfile create mode 100644 frontend/app/ai-match/page.tsx create mode 100644 frontend/app/auth/login/page.tsx create mode 100644 frontend/app/projects/[id]/page.tsx create mode 100644 frontend/components/ui/button.tsx create mode 100644 frontend/postcss.config.js create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..09fb7e3 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,60 @@ +--- +kind: pipeline +type: docker +name: freelancer-match-ci + +steps: + - name: backend-tests + image: python:3.12-slim + commands: + - pip install pytest pytest-asyncio aiosqlite httpx + - cd backend && pytest tests/ -v + + - name: frontend-lint + image: node:20-alpine + commands: + - cd frontend && npm ci + - npx next lint --dir app + + - name: build-backend + image: docker:24.0 + privileged: true + environment: + DOCKER_USERNAME: + from_secret: gitea_username + DOCKER_PASSWORD: + from_secret: gitea_password + commands: + - echo "$DOCKER_PASSWORD" | docker login ms.webhop.me -u "$DOCKER_USERNAME" --password-stdin + - cd backend && docker build -t ms.webhop.me/admin/freelancer-match-backend . + + - name: build-frontend + image: docker:24.0 + privileged: true + environment: + DOCKER_USERNAME: + from_secret: gitea_username + DOCKER_PASSWORD: + from_secret: gitea_password + commands: + - echo "$DOCKER_PASSWORD" | docker login ms.webhop.me -u "$DOCKER_USERNAME" --password-stdin + - cd frontend && docker build -t ms.webhop.me/admin/freelancer-match-frontend . + + - name: deploy + image: alpine:3.19 + commands: + - apk add curl bash + - | + echo "Deploying to production..." + # TODO: Добавить SSH ключ для деплоя на сервер + # ssh root@ms.webhop.me "cd /opt/gitea && docker compose pull && docker compose up -d" + +--- +kind: secret +name: gitea_username +from_secret: drone_gitea_user + +--- +kind: secret +name: gitea_password +from_secret: drone_gitea_pass diff --git a/README.md b/README.md index fbdb379..c4c5b8c 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,137 @@ -# 🤝 Freelancer Match — Умная площадка для фрилансеров +# Freelancer Match — Умная площадка для фрилансеров -Площадка с AI-матчингом и escrow-гарантом сделок. +Площадка для фрилансеров и заказчиков с AI-подбором, escrow-гарантом сделок и рейтинговой системой. -## Архитектура +## 🚀 Быстрый старт -``` -┌───────────────┐ ┌───────────────┐ ┌───────────────┐ -│ Next.js │ │ FastAPI │ │ Redis │ -│ (Frontend) │◄──►│ (Backend) │◄──►│ (Cache/Queue)│ -└───────────────┘ └───────┬───────┘ └───────────────┘ - │ - ┌───────▼───────┐ - │ PostgreSQL + │ - │ pgvector │ - └───────────────┘ -``` +### Требования +- Docker + Docker Compose +- Python 3.12+ (для локальной разработки) +- Node.js 20+ (для фронтенда) +- PostgreSQL 16+ +- Redis 7+ -## Стек - -| Компонент | Технология | -|-----------|------------| -| Backend | Python 3.12, FastAPI, SQLAlchemy 2.0 | -| Frontend | Next.js 14+, Tailwind CSS, shadcn/ui | -| БД | PostgreSQL 16 + pgvector (AI-эмбеддинги) | -| Кэш/Очереди | Redis 7+ | -| AI | OpenAI embeddings + LLM для матчинга | -| Платежи | Stripe Connect (escrow-гарант) | - -## Быстрый старт +### Запуск через Docker Compose ```bash -# Запуск инфраструктуры -docker compose up -d postgres redis +# 1. Скопируйте .env.example в .env и заполните переменные +cp backend/.env.example backend/.env -# Backend +# 2. Запустите стек +docker compose up -d --build + +# 3. Примените миграции БД +docker compose exec backend alembic upgrade head + +# 4. Проверьте что всё работает +curl http://localhost:8000/api/health +``` + +### Локальная разработка + +#### Backend +```bash cd backend -cp .env.example .env # настройте переменные +python -m venv .venv +source .venv/bin/activate pip install -r requirements.txt -uvicorn app.main:app --reload +uvicorn app.main:app --reload --port 8000 +``` -# Frontend +#### Frontend +```bash cd frontend -npm install +npm ci npm run dev ``` -## API Endpoints +## 📁 Структура проекта + +``` +freelancer-match/ +├── backend/ # FastAPI бэкенд +│ ├── app/ +│ │ ├── api/ # API endpoints +│ │ ├── models/ # SQLAlchemy модели +│ │ ├── schemas/ # Pydantic схемы +│ │ ├── services/ # Бизнес-логика +│ │ └── core/ # Базовые модули (БД, безопасность) +│ ├── alembic/ # Миграции БД +│ ├── tests/ # Тесты pytest +│ ├── Dockerfile +│ └── requirements.txt +├── frontend/ # Next.js фронтенд +│ ├── app/ # App Router (pages) +│ ├── components/ui/ # UI компоненты +│ ├── lib/ # Утилиты и провайдеры +│ ├── Dockerfile +│ └── package.json +├── docker-compose.yml # Стек сервисов +└── .drone.yml # CI/CD для Drone CI +``` + +## 🔑 Переменные окружения + +| Переменная | Описание | Пример | +|------------|----------|--------| +| `DATABASE_URL` | Подключение к PostgreSQL | `postgresql+asyncpg://user:pass@host/db` | +| `REDIS_URL` | Подключение к Redis | `redis://localhost:6379/0` | +| `SECRET_KEY` | Ключ для JWT токенов | Сгенерируйте через `openssl rand -hex 32` | +| `OPENAI_API_KEY` | API ключ OpenAI для AI-матчинга | `sk-...` | +| `STRIPE_SECRET_KEY` | Ключ Stripe для escrow | `sk_test_...` | +| `SMTP_*` | Настройки email | smtp.gmail.com:587 | + +## 🧪 Тесты + +```bash +# Backend (pytest) +cd backend && pytest tests/ -v + +# Frontend (linting) +cd frontend && npx next lint +``` + +## 🔒 Безопасность + +- JWT аутентификация с refresh токенами +- Хеширование паролей через bcrypt +- CORS middleware +- Escrow-гарант сделок (Stripe интеграция) +- AI-матчинг с pgvector эмбеддингами + +## 📊 API Endpoints | Метод | Endpoint | Описание | |-------|----------|----------| -| POST | `/api/auth/register` | Регистрация | -| POST | `/api/auth/login` | Логин (JWT) | -| GET | `/api/projects?status=open` | Список проектов | +| POST | `/api/auth/register` | Регистрация пользователя | +| POST | `/api/auth/login` | Вход (JWT) | +| GET | `/api/projects` | Список проектов | | POST | `/api/projects` | Создать проект | | POST | `/api/ai/match-project` | AI-подбор фрилансеров | | POST | `/api/escrow/create` | Создать escrow-транзакцию | -## Деплой +## 🚀 Деплой на сервер ```bash -# Backend → Railway / AWS ECS -# Frontend → Vercel -# БД → Supabase / AWS RDS -# Redis → Upstash +# 1. Настройте .env.production +cp backend/.env.example backend/.env.production + +# 2. Обновите docker-compose.yml для продакшена (уберите порты, добавьте healthcheck) + +# 3. Запустите через Docker Compose +docker compose -f docker-compose.prod.yml up -d --build + +# 4. Примените миграции +docker compose exec backend alembic upgrade head ``` -## Лицензия +## 🤖 CI/CD (Drone CI) + +Проект настроен для автоматического деплоя через Drone CI: +- `.drone.yml` — пайплайн с тестами, сборкой и деплоем +- Настроен на Gitea (`ms.webhop.me`) +- Автоматический запуск при push в main + +## 📝 Лицензия MIT © 2026 Freelancer Match diff --git a/backend/.env.example b/backend/.env.example index 98f45af..bc0ff99 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,24 +1,33 @@ +# Database DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/freelancer_match + +# Redis REDIS_URL=redis://localhost:6379/0 +# JWT SECRET_KEY=your-secret-key-change-in-production ACCESS_TOKEN_EXPIRE_MINUTES=15 REFRESH_TOKEN_EXPIRE_DAYS=7 +# OpenAI (для AI-матчинга) OPENAI_API_KEY=sk-... EMBEDDING_MODEL=text-embedding-3-small +# OAuth GOOGLE_CLIENT_ID=... GITHUB_CLIENT_ID=... GITHUB_CLIENT_SECRET=... +# Stripe (Escrow) STRIPE_SECRET_KEY=sk_test_... STRIPE_WEBHOOK_SECRET=whsec_... +# Email SMTP_HOST=smtp.gmail.com SMTP_PORT=587 -SMTP_USER=... +SMTP_USER=noreply@freelancermatch.com SMTP_PASSWORD=... EMAIL_FROM=noreply@freelancermatch.com +# CORS ALLOWED_ORIGINS=["http://localhost:3000","https://freelancermatch.com"] diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..3f47e7f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim AS base + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/schemas/ai_match.py b/backend/app/schemas/ai_match.py new file mode 100644 index 0000000..f46cbcc --- /dev/null +++ b/backend/app/schemas/ai_match.py @@ -0,0 +1,17 @@ +"""Схемы AI-матчинга.""" + +from pydantic import BaseModel, Field + + +class AIMatchRequest(BaseModel): + project_id: str = Field(..., description="ID проекта") + limit: int = Field(default=10, ge=1, le=50) + min_score: float | None = Field(default=None, ge=0.0, le=1.0) + + +class AIMatchResponse(BaseModel): + freelancer_id: str + name: str + skills_matched: list[str] + match_score: float + reasons: list[str] diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..367b535 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,8 @@ +"""Схемы авторизации.""" + +from pydantic import BaseModel + + +class TokenPair(BaseModel): + access_token: str + refresh_token: str diff --git a/backend/app/schemas/escrow.py b/backend/app/schemas/escrow.py new file mode 100644 index 0000000..cbb7735 --- /dev/null +++ b/backend/app/schemas/escrow.py @@ -0,0 +1,14 @@ +"""Схемы escrow-транзакций.""" + +from pydantic import BaseModel, Field + + +class EscrowCreate(BaseModel): + project_id: str = Field(..., description="ID проекта") + client_id: str = Field(..., description="ID клиента") + freelancer_id: str = Field(..., description="ID фрилансера") + amount: float = Field(..., gt=0, description="Сумма в рублях") + + +class EscrowRelease(BaseModel): + transaction_id: str = Field(..., description="ID транзакции") diff --git a/backend/requirements.txt b/backend/requirements.txt index ce42a82..33d642a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,6 +4,7 @@ sqlalchemy[asyncio]==2.0.37 alembic==1.14.1 asyncpg==0.30.0 pydantic-settings==2.7.1 +pydantic[email]==2.9.2 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 openai==1.58.1 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..a31e7d5 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +# Tests for freelancer-match backend diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..4c131f9 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,83 @@ +"""Конфигурация тестов.""" + +import asyncio +from typing import AsyncGenerator + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker + +from app.core.database import Base + + +# Создаём тестовую БД в памяти (SQLite) +TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" + +engine = create_async_engine(TEST_DATABASE_URL, echo=False) +async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +@pytest.fixture(scope="session") +def event_loop(): + """Создаём event loop для тестов.""" + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest_asyncio.fixture(autouse=True) +async def setup_db(): + """Создаём таблицы перед каждым тестом и удаляем после.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest_asyncio.fixture +async def db_session() -> AsyncGenerator[AsyncSession, None]: + """Тестовая сессия БД.""" + async with async_session_factory() as session: + yield session + + +@pytest_asyncio.fixture +async def client(db_session) -> AsyncGenerator[AsyncClient, None]: + """HTTP клиент для тестирования API.""" + from app.main import app + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + +@pytest_asyncio.fixture +async def test_user(db_session): + """Создаём тестового пользователя.""" + from app.models.user import User + + user = User(email="test@example.com", password_hash="$2b$12$LJ3m4ys6LJ3m4ys6LJ3m4e", role="freelancer") + db_session.add(user) + await db_session.commit() + return user + + +@pytest_asyncio.fixture +async def test_project(db_session, test_user): + """Создаём тестовый проект.""" + from app.models.project import Project + + project = Project( + client_id=test_user.id, + title="Тестовый проект", + description="Описание проекта для тестирования", + category="web-development", + required_skills=["python", "fastapi"], + budget_min=1000.0, + budget_max=5000.0, + ) + db_session.add(project) + await db_session.commit() + return project diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..0329d4b --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,13 @@ +"""Тесты для health endpoint.""" + +import pytest + + +@pytest.mark.asyncio +async def test_health(client): + """Проверка что health endpoint возвращает ok.""" + response = await client.get("/api/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + assert data["service"] == "freelancer-match" diff --git a/docker-compose.yml b/docker-compose.yml index 7078645..62afcaa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,18 +4,52 @@ services: postgres: image: postgres:16-alpine environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_DB: freelancer_match ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 redis: image: redis:7-alpine ports: - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: ./backend + environment: + DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/freelancer_match + REDIS_URL: redis://redis:6379/0 + SECRET_KEY: ${SECRET_KEY} + OPENAI_API_KEY: ${OPENAI_API_KEY} + ports: + - "8000:8000" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + frontend: + build: ./frontend + environment: + NEXT_PUBLIC_API_URL: http://backend:8000 + ports: + - "3000:3000" + depends_on: + - backend volumes: pgdata: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..e171cc5 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine AS base + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . + +EXPOSE 3000 + +CMD ["npm", "run", "dev"] diff --git a/frontend/app/ai-match/page.tsx b/frontend/app/ai-match/page.tsx new file mode 100644 index 0000000..dbcaac7 --- /dev/null +++ b/frontend/app/ai-match/page.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +export default function AIMatchPage() { + const [projectId, setProjectId] = useState(""); + const [loading, setLoading] = useState(false); + const [matches, setMatches] = useState([]); + const [error, setError] = useState(""); + + async function handleMatch() { + if (!projectId) return; + setLoading(true); + try { + const res = await fetch("/api/ai/match-project", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ project_id: projectId, limit: 10 }), + }); + + if (!res.ok) throw new Error("Ошибка при поиске совпадений"); + + const data = await res.json(); + setMatches(data); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + } + + return ( +
+
+ ← Назад +

AI-матчинг

+
+ +
+
+

Подобрать фрилансеров

+ + setProjectId(e.target.value)} + placeholder="ID проекта" + className="w-full px-4 py-3 border rounded-lg mb-4" + /> + + {error && ( +
{error}
+ )} + + +
+ + {matches.length > 0 && ( +
+ {matches.map((m, i) => ( +
+
+

{m.name}

+ {(m.match_score * 100).toFixed(0)}% +
+ + {m.skills_matched.length > 0 && ( +

Совпадение навыков: {m.skills_matched.join(", ")}

+ )} + +
    + {m.reasons.map((r, j) => ( +
  • • {r}
  • + ))} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/frontend/app/auth/login/page.tsx b/frontend/app/auth/login/page.tsx new file mode 100644 index 0000000..a0ba3a2 --- /dev/null +++ b/frontend/app/auth/login/page.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string().min(1), +}); + +type FormData = z.infer; + +export default function LoginPage() { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const form = useForm({ resolver: zodResolver(loginSchema) }); + + async function onSubmit(data: FormData) { + setLoading(true); + try { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + if (!res.ok) throw new Error("Неверный email или пароль"); + + router.push("/dashboard"); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + } + + return ( +
+
+

Вход

+ + {error && ( +
{error}
+ )} + + + {form.formState.errors.email && ( +

{form.formState.errors.email.message}

+ )} + + + {form.formState.errors.password && ( +

{form.formState.errors.password.message}

+ )} + + + +

+ Нет аккаунта?{" "} + Зарегистрироваться +

+
+
+ ); +} diff --git a/frontend/app/projects/[id]/page.tsx b/frontend/app/projects/[id]/page.tsx new file mode 100644 index 0000000..253f926 --- /dev/null +++ b/frontend/app/projects/[id]/page.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; + +async function fetchProject(id: string) { + const res = await fetch(`/api/projects/${id}`); + if (!res.ok) throw new Error("Проект не найден"); + return res.json(); +} + +export default function ProjectPage() { + const params = useParams<{ id: string }>(); + const router = useRouter(); + + const { data: project, isLoading } = useQuery({ + queryKey: ["project", params.id], + queryFn: () => fetchProject(params.id), + }); + + if (isLoading) return
Загрузка...
; + if (!project) return
Проект не найден
; + + return ( +
+
+ ← Назад +

Freelancer Match

+
+ +
+
+

{project.title}

+

{project.description}

+ + {project.category && ( + + {project.category} + + )} + +
+ {project.required_skills.map((skill: string) => ( + {skill} + ))} +
+ + {(project.budget_min || project.budget_max) && ( +
+ {project.budget_min ? `${project.budget_min.toLocaleString()}₽` : "—"} —{" "} + {project.budget_max ? `${project.budget_max.toLocaleString()}₽` : "до бесконечности"} +
+ )} + + {project.deadline && ( +

Дедлайн: {new Date(project.deadline).toLocaleDateString("ru-RU")}

+ )} +
+ + +
+
+ ); +} diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx new file mode 100644 index 0000000..dab698e --- /dev/null +++ b/frontend/components/ui/button.tsx @@ -0,0 +1,51 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-blue-600 text-white hover:bg-blue-700", + destructive: "bg-red-500 text-white hover:bg-red-600", + outline: "border border-gray-300 bg-transparent hover:bg-gray-100", + secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300", + ghost: "hover:bg-gray-100", + link: "text-blue-600 underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8 text-base", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + } +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..649398e --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./app/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..eea7ea5 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}