Sun Oct 26 15:44:27 CET 2025
This commit is contained in:
commit
4b746447e0
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@ -0,0 +1,7 @@
|
||||
.next
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
64
.gitignore
vendored
Normal file
64
.gitignore
vendored
Normal file
@ -0,0 +1,64 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# Dependencies (all node_modules in any subdirectory)
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Next.js (all .next directories in any subdirectory)
|
||||
.next/
|
||||
out/
|
||||
dist/
|
||||
|
||||
# Production builds
|
||||
build/
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
*.log
|
||||
|
||||
# Environment files (IMPORTANT: never commit .env with secrets!)
|
||||
.env
|
||||
.env*.local
|
||||
.env.production
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Prisma
|
||||
prisma/migrations/
|
||||
node_modules/.prisma/
|
||||
.prisma/
|
||||
|
||||
# Docker (keep docker-compose.yml and Dockerfiles in Git)
|
||||
docker-compose.override.yml
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
324
README.md
Normal file
324
README.md
Normal file
@ -0,0 +1,324 @@
|
||||
# SportBuddy
|
||||
|
||||
Moderná webová aplikácia pre športových nadšencov - hľadanie spoluhráčov, organizácia športových aktivít a prehľad športovísk.
|
||||
|
||||
## Technológie
|
||||
|
||||
**Frontend:** Next.js (latest), React (latest), TypeScript (latest), Tailwind CSS (latest)
|
||||
**Backend:** Next.js API Routes, Prisma ORM (latest), Better Auth (latest), PostgreSQL (latest)
|
||||
**DevOps:** Docker & Docker Compose, Node.js (alpine), npm Workspaces
|
||||
|
||||
> **Poznámka:** Všetky verzie používajú latest Alpine Linux images a npm packages pre najnovšie stabilné vydania.
|
||||
|
||||
---
|
||||
|
||||
## Rýchly štart
|
||||
|
||||
### Predpoklady
|
||||
- [Docker Desktop](https://www.docker.com/products/docker-desktop) (najnovšia verzia)
|
||||
- Git
|
||||
- Ideálne WSL2 (Docker Engine nech beží tiež na WSL2)
|
||||
|
||||
### Inštalácia (3 kroky)
|
||||
|
||||
```bash
|
||||
# 1. Klonuj projekt
|
||||
git clone https://github.com/your-username/sportbuddy.git
|
||||
cd sportbuddy
|
||||
|
||||
# 2. Spusti Docker Compose (automaticky stiahne dependencies a spustí všetky služby)
|
||||
docker-compose up -d
|
||||
|
||||
# 3. Otvor aplikáciu v prehliadači
|
||||
# Frontend: http://localhost:3000
|
||||
# Backend API: http://localhost:3001/api
|
||||
```
|
||||
|
||||
Prvé spustenie trvá ~1-2 minúty (sťahovanie images + npm install).
|
||||
|
||||
---
|
||||
|
||||
## Pre vývojárov
|
||||
|
||||
### Pri prvom spustení?
|
||||
|
||||
1. **Docker stiahne images:**
|
||||
- `postgres:alpine` (databáza)
|
||||
- `node:alpine` (Node.js runtime)
|
||||
|
||||
2. **Backend automaticky:**
|
||||
- Nainštaluje npm dependencies
|
||||
- Vygeneruje Prisma Client
|
||||
- Vytvorí databázové tabuľky (`prisma db push`)
|
||||
- Spustí development server na porte **3001**
|
||||
|
||||
3. **Frontend automaticky:**
|
||||
- Nainštaluje npm dependencies
|
||||
- Spustí development server na porte **3000**
|
||||
|
||||
4. **PostgreSQL:**
|
||||
- Vytvorí databázu `sportbuddy`
|
||||
- Beží na porte **5432**
|
||||
|
||||
### Hot Reload (automatické načítanie zmien)
|
||||
|
||||
Vďaka **volume mounts** - okamžitý hot reload:
|
||||
|
||||
```
|
||||
Zmeníš súbor → Uložíš (Ctrl+S) → Zmena sa okamžite prejaví v prehliadači
|
||||
```
|
||||
|
||||
Nemusíš reštartovať Docker kontajnery!
|
||||
|
||||
---
|
||||
|
||||
## Docker príkazy pre development
|
||||
|
||||
```bash
|
||||
# Spustenie všetkých služieb
|
||||
docker-compose up -d
|
||||
|
||||
# Sledovanie logov (užitočné pre debugging)
|
||||
docker-compose logs -f
|
||||
docker-compose logs -f backend # len backend
|
||||
docker-compose logs -f frontend # len frontend
|
||||
|
||||
# Rebuild po zmene Dockerfile
|
||||
docker-compose up -d --build
|
||||
|
||||
# Zastavenie služieb
|
||||
docker-compose down
|
||||
|
||||
# Vyčistenie všetkého (vrátane databázy!)
|
||||
docker-compose down -v
|
||||
|
||||
# Exec do kontajnera (pre manuálne príkazy)
|
||||
docker-compose exec backend sh
|
||||
docker-compose exec frontend sh
|
||||
|
||||
# Prisma Studio (GUI pre databázu)
|
||||
docker-compose exec backend npx prisma studio
|
||||
# Otvor: http://localhost:5555
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Štruktúra projektu
|
||||
|
||||
```
|
||||
sportbuddy/
|
||||
├── apps/
|
||||
│ ├── backend/ # Backend API (Next.js + Prisma)
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── app/api/ # API endpoints
|
||||
│ │ │ └── lib/ # Server utilities (auth, prisma)
|
||||
│ │ ├── prisma/
|
||||
│ │ │ └── schema.prisma # Databázová schéma
|
||||
│ │ ├── Dockerfile # Unifikovaný Docker image (dev + prod)
|
||||
│ │ └── package.json
|
||||
│ │
|
||||
│ └── frontend/ # Frontend UI (Next.js + React)
|
||||
│ ├── src/
|
||||
│ │ ├── app/ # Next.js App Router stránky
|
||||
│ │ ├── components/ # React komponenty
|
||||
│ │ └── contexts/ # React Context (theme, atď.)
|
||||
│ ├── Dockerfile # Unifikovaný Docker image (dev + prod)
|
||||
│ └── package.json
|
||||
│
|
||||
├── packages/
|
||||
│ └── shared/ # Zdieľané TypeScript typy
|
||||
│ └── src/types/ # SportType, SkillLevel, atď.
|
||||
│
|
||||
├── docker-compose.yml # Docker konfigurácia
|
||||
├── .env # Environment variables (lokálne, NIE v Gite!)
|
||||
├── .env.example # Template (commituj do Gitu)
|
||||
├── README.md # Návod na používanie
|
||||
└── USER_STORIES.md # Prehľad user stories a taskov
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Konfigurácia (.env súbor)
|
||||
|
||||
Projekt používa jeden `.env` súbor v roote. **Pre produkčný build `.env` do Gitu! necommitovať**
|
||||
|
||||
### Premenné v .env:
|
||||
|
||||
```properties
|
||||
# PostgreSQL
|
||||
POSTGRES_USER=sportbuddy
|
||||
POSTGRES_PASSWORD=sportbuddy123
|
||||
POSTGRES_DB=sportbuddy
|
||||
|
||||
# Backend
|
||||
DATABASE_URL="postgresql://sportbuddy:sportbuddy123@postgres:5432/sportbuddy"
|
||||
BETTER_AUTH_SECRET="change-this-in-production"
|
||||
BETTER_AUTH_URL="http://localhost:3001"
|
||||
|
||||
# Frontend
|
||||
NEXT_PUBLIC_API_URL="http://localhost:3001"
|
||||
|
||||
# OAuth (voliteľné)
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GOOGLE_CLIENT_SECRET=""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development workflow
|
||||
|
||||
### 1. Práca na user stories
|
||||
|
||||
Pozri [USER_STORIES.md](USER_STORIES.md) pre aktuálny stav projektu a zoznam taskov.
|
||||
|
||||
### 2. Práca s databázou (Prisma)
|
||||
|
||||
```bash
|
||||
# Zmena schémy (prisma/schema.prisma)
|
||||
# → Potom spusti:
|
||||
docker-compose exec backend npx prisma db push
|
||||
|
||||
# Otvor Prisma Studio (GUI)
|
||||
docker-compose exec backend npx prisma studio
|
||||
|
||||
# Reset databázy (POZOR: vymaže všetky dáta!)
|
||||
docker-compose exec backend npx prisma db push --force-reset
|
||||
```
|
||||
|
||||
### 3. Debugging
|
||||
|
||||
```bash
|
||||
# Backend logy (API requesty, errory)
|
||||
docker-compose logs -f backend
|
||||
|
||||
# Frontend logy (build output, errors)
|
||||
docker-compose logs -f frontend
|
||||
|
||||
# Databáza logy
|
||||
docker-compose logs -f postgres
|
||||
```
|
||||
|
||||
### 4. Pridávanie nových dependencies
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
docker-compose exec backend npm install <package>
|
||||
# Potom restart
|
||||
docker-compose restart backend
|
||||
|
||||
# Frontend
|
||||
docker-compose exec frontend npm install <package>
|
||||
# Potom restart
|
||||
docker-compose restart frontend
|
||||
```
|
||||
|
||||
**Tip:** Po `npm install` v kontajneri, zmeň aj `package.json` lokálne a commitni ho.
|
||||
|
||||
---
|
||||
|
||||
## Docker architektúra
|
||||
|
||||
### Unifikované Dockerfiles
|
||||
|
||||
Každý Dockerfile má **multi-stage build** s dvoma režimami:
|
||||
|
||||
- **Development stage** (používa docker-compose.yml)
|
||||
- Hot reload cez volume mounts
|
||||
- `npm run dev`
|
||||
- Debug-friendly
|
||||
|
||||
- **Production stage** (pre nasadenie)
|
||||
- Optimalizovaný build
|
||||
- Multi-stage image (menší size)
|
||||
- `npm run build`
|
||||
|
||||
### Ako to funguje?
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml používa 'development' target
|
||||
backend:
|
||||
build:
|
||||
context: ./apps/backend
|
||||
target: development # ← Development režim
|
||||
```
|
||||
|
||||
### Produkčný build (pre nasadenie)
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd apps/backend
|
||||
docker build --target production -t sportbuddy-backend .
|
||||
|
||||
# Frontend
|
||||
cd apps/frontend
|
||||
docker build --target production -t sportbuddy-frontend .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pre vývojárov - Best Practices
|
||||
|
||||
### ✅ Commituj:
|
||||
- Všetok kód v `apps/*/src/`
|
||||
- `package.json`, `package-lock.json` (po pridaní dependencies)
|
||||
- `prisma/schema.prisma` (po zmene schémy)
|
||||
- `Dockerfile`, `docker-compose.yml`
|
||||
- `.env.example` (template bez secrets)
|
||||
|
||||
### ❌ Necommituj:
|
||||
- `node_modules/` (automaticky ignorované)
|
||||
- `.env` (vývojarská verzia áno, produkčná dať do secrets!)
|
||||
- `.vscode/`, `.idea/` (IDE nastavenia)
|
||||
|
||||
### 🔄 Po každom git pull:
|
||||
|
||||
```bash
|
||||
# Ak niekto zmenil Dockerfile alebo dependencies
|
||||
docker-compose down
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
### 🐛 Keď niečo nefunguje:
|
||||
|
||||
```bash
|
||||
# 1. Skús restart
|
||||
docker-compose restart
|
||||
|
||||
# 2. Skús rebuild
|
||||
docker-compose up -d --build
|
||||
|
||||
# 3. Vyčisti všetko a začni odznova
|
||||
docker-compose down -v
|
||||
docker-compose up -d --build
|
||||
|
||||
# 4. Skontroluj logy
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ďalšie kroky
|
||||
|
||||
1. **Prečítaj si** [USER_STORIES.md](USER_STORIES.md) - zoznam všetkých user stories a ich stav
|
||||
2. **Vyber si task**
|
||||
3. **Pozri databázovú schému** - `apps/backend/prisma/schema.prisma`
|
||||
4. **Pozri API - backend** - `apps/backend/src/app/api/`
|
||||
|
||||
---
|
||||
|
||||
## Oficiálna dokumentácia
|
||||
|
||||
- [Next.js Docs](https://nextjs.org/docs)
|
||||
- [React Docs](https://react.dev)
|
||||
- [Prisma Docs](https://www.prisma.io/docs)
|
||||
- [Better Auth Docs](https://www.better-auth.com/docs)
|
||||
- [Tailwind CSS Docs](https://tailwindcss.com/docs)
|
||||
- [Docker Docs](https://docs.docker.com/)
|
||||
|
||||
---
|
||||
|
||||
## Kontakt
|
||||
|
||||
Pre otázky ohľadom projektu kontaktuj tím alebo otvor issue v repozitári.
|
||||
248
USER_STORIES.md
Normal file
248
USER_STORIES.md
Normal file
@ -0,0 +1,248 @@
|
||||
# SportBuddy - User Stories
|
||||
|
||||
---
|
||||
|
||||
## US-001: Registrácia a prihlásenie
|
||||
|
||||
**Status:** ✅ HOTOVÉ
|
||||
|
||||
Ako nový používateľ
|
||||
chcem sa zaregistrovať a prihlásiť
|
||||
aby som mohol používať aplikáciu
|
||||
|
||||
**Vývojár:** Kamil Berecký
|
||||
|
||||
### Tasky:
|
||||
- ✅ Setup Next.js projekt + Tailwind + BetterAuth
|
||||
- ✅ PostgreSQL databáza
|
||||
- ✅ Prisma schema: User model
|
||||
- ✅ BetterAuth konfigurácia (credentials provider)
|
||||
- ✅ Registračný formulár (/register)
|
||||
- ✅ Prihlasovací formulár (/login)
|
||||
- ✅ Validácia (email formát, heslo min 8 znakov)
|
||||
- ✅ Hash hesla (bcrypt)
|
||||
- ✅ Session management (localStorage)
|
||||
- ✅ Responzívny dizajn formulárov
|
||||
|
||||
### Výsledné funkcie:
|
||||
- ✅ Fungujúca registrácia
|
||||
- ✅ Fungujúce prihlásenie
|
||||
- ✅ Session persistence
|
||||
- ✅ Redirect na /dashboard po prihlásení
|
||||
|
||||
---
|
||||
|
||||
## US-002: Používateľský profil & Dashboard
|
||||
|
||||
**Status:** 📋 PLANNED
|
||||
|
||||
Ako používateľ
|
||||
chcem vidieť a upraviť môj profil a dashboard s mojimi aktivitami
|
||||
aby som mohol prezentovať svoje športové záujmy a mať prehľad o mojich udalostiach
|
||||
|
||||
**Vývojár:** -
|
||||
|
||||
### Tasky:
|
||||
#### Profil sekcia
|
||||
- ⏸️ Profil stránka (/profile) - **NEIMPLEMENTOVANÉ**
|
||||
- ⏸️ Zobrazenie: meno, email, mesto, bio, obľúbené športy
|
||||
- ⏸️ Formulár na editáciu profilu (/profile/edit)
|
||||
- ⏸️ Upload profilovej fotky
|
||||
- ⏸️ API: GET /api/profile
|
||||
- ⏸️ API: PUT /api/profile
|
||||
- ⏸️ Validácia formulára
|
||||
- ⏸️ Responzívny dizajn
|
||||
|
||||
#### Dashboard sekcia
|
||||
- ⏸️ Dashboard stránka (/dashboard) - **EXISTUJE ale je prázdny**
|
||||
- ⏸️ API: GET /api/activities/my (filtrovanie podľa userId)
|
||||
- ⏸️ Dve sekcie: "Moje aktivity" (vytvorené) a "Prihlásený na" (joined)
|
||||
- ⏸️ Používanie Activity card komponentu
|
||||
- ⏸️ Loading state
|
||||
- ⏸️ Empty states
|
||||
- ⏸️ Quick actions: "Vytvoriť aktivitu", "Hľadať aktivity"
|
||||
- ⏸️ Štatistiky: počet aktivít, počet prihlásení - **Základné karty existujú**
|
||||
- ⏸️ Responzívny layout
|
||||
|
||||
### Výsledné funkcie:
|
||||
- ⏸️ Zobrazenie profilu
|
||||
- ⏸️ Editácia profilu
|
||||
- ⏸️ Upload fotky
|
||||
- ⏸️ Dashboard so zoznamom - **Dashboard existuje, ale nezobrazuje zoznam aktivít**
|
||||
- ⏸️ Filtrovanie funguje
|
||||
- ⏸️ Štatistiky sa zobrazujú - **Iba placeholder štatistiky (0, 0, -)**
|
||||
|
||||
**Poznámka:** Profil model existuje v databáze (User + Profile), ale žiadne UI stránky nie sú implementované. Dashboard stránka existuje s navigáciou a základnou štruktúrou (3 štatistické karty), ale neobsahuje žiadne reálne dáta ani zoznam aktivít.
|
||||
|
||||
---
|
||||
|
||||
## US-003: Vytvorenie aktivity
|
||||
|
||||
**Status:** 🔄 WIP (Work In Progress)
|
||||
|
||||
Ako používateľ
|
||||
chcem vytvoriť novú športovú aktivitu
|
||||
aby som našiel spoluhráčov
|
||||
|
||||
**Vývojár:** -
|
||||
|
||||
### Tasky:
|
||||
- ✅ Prisma schema: Activity model
|
||||
- ✅ API: POST /api/activities
|
||||
- ✅ Automatické pridanie tvorcu ako účastníka
|
||||
- ✅ Validácia (dátum v budúcnosti, cena >= 0)
|
||||
- ⏸️ Formulár na vytvorenie (/activities/create)
|
||||
- ⏸️ Polia: názov, šport (dropdown), dátum, čas, miesto, max hráčov, úroveň, cena, popis
|
||||
- ⏸️ React Hook Form + Zod validácia
|
||||
- ⏸️ Loading state pri submit
|
||||
- ⏸️ Redirect na detail po vytvorení
|
||||
- ⏸️ Responzívny formulár
|
||||
|
||||
### Výsledné funkcie:
|
||||
- ✅ API endpoint funguje
|
||||
- ✅ Funkčná validácia na BE
|
||||
- ✅ Aktivita sa uloží do DB
|
||||
- ⏸️ Frontend formulár chýba
|
||||
|
||||
**Poznámka:** API je hotové, ale chýba frontend formulár na vytvorenie aktivity.
|
||||
|
||||
---
|
||||
|
||||
## US-004: Zoznam a detail aktivít
|
||||
|
||||
**Status:** 🔄 WIP (Work In Progress)
|
||||
|
||||
Ako používateľ
|
||||
chcem vidieť zoznam aktivít a ich detail
|
||||
aby som vedel, čo je k dispozícii
|
||||
|
||||
**Vývojár:** -
|
||||
|
||||
### Tasky:
|
||||
- ✅ API: GET /api/activities (pagination 20/page)
|
||||
- ✅ API: GET /api/activities/[id]
|
||||
- ✅ Filtrovanie podľa športu, mesta, statusu
|
||||
- ⏸️ Stránka zoznamu (/activities)
|
||||
- ⏸️ Card komponenta pre aktivitu
|
||||
- ⏸️ Zobrazenie: názov, šport, dátum, čas, miesto, voľné miesta
|
||||
- ⏸️ Loading skeleton
|
||||
- ⏸️ Empty state ("Žiadne aktivity")
|
||||
- ⏸️ Detail stránka (/activities/[id])
|
||||
- ⏸️ Kompletné info + mapa (Google Maps embed)
|
||||
- ⏸️ Zoznam účastníkov
|
||||
- ⏸️ Progress bar obsadenosti
|
||||
- ⏸️ Responzívny grid/detail
|
||||
|
||||
### Výsledné funkcie:
|
||||
- ✅ API endpoints fungujú
|
||||
- ⏸️ Zoznam aktivít (UI)
|
||||
- ⏸️ Detail aktivity (UI)
|
||||
- ⏸️ Pagination funguje
|
||||
|
||||
**Poznámka:** Backend API je kompletný s filtrovaním, ale chýba celý frontend UI.
|
||||
|
||||
---
|
||||
|
||||
## US-005: Prihlasovanie na aktivity
|
||||
|
||||
**Status:** 🔄 WIP (Work In Progress)
|
||||
|
||||
Ako používateľ
|
||||
chcem sa prihlásiť na aktivitu
|
||||
aby som rezervoval miesto
|
||||
|
||||
**Vývojár:** -
|
||||
|
||||
### Tasky:
|
||||
- ✅ Prisma schema: Booking model (many-to-many User-Activity)
|
||||
- ✅ API: POST /api/activities/[id]/join
|
||||
- ✅ Kontrola voľnej kapacity
|
||||
- ✅ Kontrola duplicity (už prihlásený)
|
||||
- ✅ Aktualizácia počtu hráčov
|
||||
- ⏸️ Tlačidlo "Prihlásiť sa" na detaile
|
||||
- ⏸️ Tlačidlo "Odhlásiť sa" ak som prihlásený
|
||||
- ⏸️ API: DELETE /api/activities/[id]/leave
|
||||
- ⏸️ Optimistic updates (TanStack Query)
|
||||
- ⏸️ Toast notifikácie
|
||||
- ⏸️ Badge "Prihlásený" na karte aktivity
|
||||
|
||||
### Výsledné funkcie:
|
||||
- ✅ Prihlásenie funguje (API)
|
||||
- ⏸️ Odhlásenie funguje
|
||||
- ✅ Počet hráčov sa aktualizuje
|
||||
- ⏸️ UI komponenty chýbajú
|
||||
|
||||
**Poznámka:** Join API je hotové, ale chýba leave endpoint a všetky UI elementy.
|
||||
|
||||
---
|
||||
|
||||
## US-006: Vyhľadávanie aktivít
|
||||
|
||||
**Status:** 🔄 WIP (Work In Progress)
|
||||
|
||||
Ako používateľ
|
||||
chcem vyhľadávať aktivity
|
||||
aby som rýchlo našiel, čo ma zaujíma
|
||||
|
||||
**Vývojár:** -
|
||||
|
||||
### Tasky:
|
||||
- ✅ API: GET /api/activities?search=... (full-text cez názov, popis)
|
||||
- ✅ Prisma search
|
||||
- ⏸️ Search bar
|
||||
- ⏸️ Search input na /activities
|
||||
- ⏸️ Loading spinner pri searchi
|
||||
- ⏸️ Highlighting výsledkov (optional)
|
||||
- ⏸️ Clear search button
|
||||
- ⏸️ "Žiadne výsledky" state
|
||||
- ⏸️ Query params v URL (?search=futbal)
|
||||
|
||||
### Výsledné funkcie:
|
||||
- ✅ Vyhľadávanie funguje (API)
|
||||
- ⏸️ Real-time výsledky (UI)
|
||||
- ⏸️ URL synchronizácia
|
||||
|
||||
**Poznámka:** API podporuje search, ale UI komponenty nie sú implementované.
|
||||
|
||||
---
|
||||
|
||||
## US-007: Základný UI/UX
|
||||
|
||||
**Status:** 🔄 WIP (Work In Progress)
|
||||
|
||||
Ako používateľ
|
||||
chcem pekné a funkčné rozhranie
|
||||
aby som mal dobrý zážitok
|
||||
|
||||
**Vývojár:** Všetci spoločne
|
||||
|
||||
### Tasky:
|
||||
- ✅ Responzívny dizajn (mobile/tablet/desktop)
|
||||
- ✅ Header s navigáciou (logo, links, user menu)
|
||||
- ✅ Footer (copyright, links)
|
||||
- ✅ Dark mode toggle
|
||||
- ⏸️ Loading states všade (skeleton, spinner)
|
||||
- ⏸️ Notifikácie (react-hot-toast)
|
||||
- ⏸️ Error states a error boundaries
|
||||
- ⏸️ 404 stránka
|
||||
- ✅ Konzistencia písma a farebných schém
|
||||
- ✅ Tailwind configurácia
|
||||
- ✅ Mobilné menu (hamburger)
|
||||
|
||||
### Výsledné funkcie:
|
||||
- ✅ Responzívny dizajn
|
||||
- ✅ Konzistentný UI
|
||||
- ⏸️ Loading/Error states
|
||||
|
||||
**Poznámka:** Základný design a theme switching je hotový, ale chýbajú loading states, error handling a notifikácie.
|
||||
|
||||
---
|
||||
|
||||
## Legenda
|
||||
|
||||
- ✅ Hotové (Completed)
|
||||
- 🔄 WIP (Work In Progress - rozrobené)
|
||||
- ⏸️ Nerealizované (Planned but not started)
|
||||
- 📋 PLANNED (Celá user story je len naplánovaná)
|
||||
|
||||
---
|
||||
23
apps/backend/.env.example
Normal file
23
apps/backend/.env.example
Normal file
@ -0,0 +1,23 @@
|
||||
# Database Configuration
|
||||
# For Docker: postgresql://sportbuddy:sportbuddy123@postgres:5432/sportbuddy?schema=public
|
||||
# For local: postgresql://sportbuddy:sportbuddy123@localhost:5432/sportbuddy?schema=public
|
||||
DATABASE_URL="postgresql://sportbuddy:sportbuddy123@postgres:5432/sportbuddy?schema=public"
|
||||
|
||||
# Better Auth Configuration
|
||||
# Generate a random 32+ character secret for production
|
||||
# Example: openssl rand -base64 32
|
||||
BETTER_AUTH_SECRET="change-this-to-a-random-secret-in-production-min-32-chars"
|
||||
|
||||
# Backend URL (used for Better Auth callbacks)
|
||||
# For Docker: http://localhost:3001
|
||||
# For production: https://your-backend-domain.com
|
||||
BETTER_AUTH_URL="http://localhost:3001"
|
||||
|
||||
# OAuth Providers (Optional)
|
||||
# Google OAuth 2.0 credentials from https://console.cloud.google.com/
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GOOGLE_CLIENT_SECRET=""
|
||||
|
||||
# Apple OAuth credentials from https://developer.apple.com/
|
||||
APPLE_CLIENT_ID=""
|
||||
APPLE_CLIENT_SECRET=""
|
||||
50
apps/backend/Dockerfile
Normal file
50
apps/backend/Dockerfile
Normal file
@ -0,0 +1,50 @@
|
||||
# Unified Dockerfile for Backend (Development & Production)
|
||||
# Usage:
|
||||
# Development: docker-compose up (uses 'development' target)
|
||||
# Production: docker build --target production -t sportbuddy-backend .
|
||||
|
||||
FROM node:alpine AS base
|
||||
RUN apk add --no-cache openssl libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Stage 1: Install dependencies
|
||||
FROM base AS deps
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Stage 2: Development (hot reload with volume mounts)
|
||||
FROM base AS development
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY prisma ./prisma/
|
||||
EXPOSE 3001
|
||||
CMD ["sh", "-c", "npx prisma generate && npx prisma db push && npm run dev"]
|
||||
|
||||
# Stage 3: Build for production
|
||||
FROM base AS builder
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
# Stage 4: Production runtime
|
||||
FROM base AS production
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3001
|
||||
ENV PORT=3001
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
28
apps/backend/middleware.ts
Normal file
28
apps/backend/middleware.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const path = request.nextUrl.pathname;
|
||||
|
||||
// Public paths - anyone can access
|
||||
const publicPaths = ['/', '/auth/signin', '/auth/signup', '/api/auth'];
|
||||
if (publicPaths.some(p => path.startsWith(p))) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Protected paths - require authentication
|
||||
const protectedPaths = ['/dashboard', '/profile', '/activities/create'];
|
||||
if (protectedPaths.some((p) => path.startsWith(p))) {
|
||||
// Get session from cookies
|
||||
const sessionToken = request.cookies.get('better-auth.session_token')?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
return NextResponse.redirect(new URL('/auth/signin', request.url));
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
|
||||
};
|
||||
22
apps/backend/next.config.mjs
Normal file
22
apps/backend/next.config.mjs
Normal file
@ -0,0 +1,22 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
webpackBuildWorker: true,
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
headers: [
|
||||
{ key: 'Access-Control-Allow-Credentials', value: 'true' },
|
||||
{ key: 'Access-Control-Allow-Origin', value: 'http://localhost:3000' },
|
||||
{ key: 'Access-Control-Allow-Methods', value: 'GET,DELETE,PATCH,POST,PUT,OPTIONS' },
|
||||
{ key: 'Access-Control-Allow-Headers', value: 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Authorization' },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
5272
apps/backend/package-lock.json
generated
Normal file
5272
apps/backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
apps/backend/package.json
Normal file
34
apps/backend/package.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@sportbuddy/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20.16.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3001",
|
||||
"lint": "next lint",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:push": "prisma db push",
|
||||
"prisma:migrate": "prisma migrate dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "latest",
|
||||
"bcryptjs": "latest",
|
||||
"better-auth": "latest",
|
||||
"next": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"zod": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "latest",
|
||||
"@types/react": "latest",
|
||||
"eslint": "latest",
|
||||
"eslint-config-next": "latest",
|
||||
"prisma": "latest",
|
||||
"typescript": "latest"
|
||||
}
|
||||
}
|
||||
213
apps/backend/prisma/schema.prisma
Normal file
213
apps/backend/prisma/schema.prisma
Normal file
@ -0,0 +1,213 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// User model for authentication and profile
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
email String @unique
|
||||
emailVerified Boolean @default(false)
|
||||
image String?
|
||||
password String?
|
||||
bio String?
|
||||
phone String?
|
||||
skillLevel SkillLevel @default(BEGINNER)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Better Auth relations
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
|
||||
// App relations
|
||||
activities Activity[]
|
||||
participations Participation[]
|
||||
reviews Review[]
|
||||
favoriteVenues VenueFavorite[]
|
||||
}
|
||||
|
||||
// Better Auth models
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
expiresAt DateTime
|
||||
token String @unique
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
accountId String
|
||||
providerId String
|
||||
accessToken String? @db.Text
|
||||
refreshToken String? @db.Text
|
||||
idToken String? @db.Text
|
||||
expiresAt DateTime?
|
||||
password String?
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([providerId, accountId])
|
||||
}
|
||||
|
||||
model Verification {
|
||||
id String @id @default(cuid())
|
||||
identifier String
|
||||
value String
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([identifier, value])
|
||||
}
|
||||
|
||||
// Sport types enumeration
|
||||
enum SportType {
|
||||
FOOTBALL
|
||||
BASKETBALL
|
||||
TENNIS
|
||||
VOLLEYBALL
|
||||
BADMINTON
|
||||
TABLE_TENNIS
|
||||
RUNNING
|
||||
CYCLING
|
||||
SWIMMING
|
||||
GYM
|
||||
OTHER
|
||||
}
|
||||
|
||||
// Skill level enumeration
|
||||
enum SkillLevel {
|
||||
BEGINNER
|
||||
INTERMEDIATE
|
||||
ADVANCED
|
||||
EXPERT
|
||||
}
|
||||
|
||||
// Activity status
|
||||
enum ActivityStatus {
|
||||
OPEN
|
||||
FULL
|
||||
CANCELLED
|
||||
COMPLETED
|
||||
}
|
||||
|
||||
// Venue (Športovisko) model
|
||||
model Venue {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
address String
|
||||
city String
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
sportTypes SportType[]
|
||||
amenities String[] // e.g., ["parking", "showers", "lockers"]
|
||||
priceRange String? // e.g., "Free", "5-10€", "10-20€"
|
||||
phone String?
|
||||
website String?
|
||||
image String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
activities Activity[]
|
||||
reviews Review[]
|
||||
favorites VenueFavorite[]
|
||||
|
||||
@@index([city])
|
||||
@@index([sportTypes])
|
||||
}
|
||||
|
||||
// Activity (Športová aktivita) model
|
||||
model Activity {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
description String?
|
||||
sportType SportType
|
||||
skillLevel SkillLevel
|
||||
date DateTime
|
||||
duration Int // in minutes
|
||||
maxParticipants Int
|
||||
currentParticipants Int @default(0)
|
||||
status ActivityStatus @default(OPEN)
|
||||
isPublic Boolean @default(true)
|
||||
venueId String
|
||||
organizerId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
|
||||
organizer User @relation(fields: [organizerId], references: [id], onDelete: Cascade)
|
||||
participations Participation[]
|
||||
|
||||
@@index([sportType])
|
||||
@@index([date])
|
||||
@@index([status])
|
||||
@@index([venueId])
|
||||
}
|
||||
|
||||
// Participation (Účasť na aktivite) model
|
||||
model Participation {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
activityId String
|
||||
status String @default("confirmed") // confirmed, pending, cancelled
|
||||
joinedAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
activity Activity @relation(fields: [activityId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, activityId])
|
||||
@@index([activityId])
|
||||
}
|
||||
|
||||
// Review (Recenzia športoviska) model
|
||||
model Review {
|
||||
id String @id @default(cuid())
|
||||
rating Int // 1-5 stars
|
||||
comment String?
|
||||
venueId String
|
||||
userId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([venueId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// Venue favorites (Obľúbené športoviská)
|
||||
model VenueFavorite {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
venueId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, venueId])
|
||||
@@index([userId])
|
||||
}
|
||||
162
apps/backend/src/app/api/activities/[id]/join/route.ts
Normal file
162
apps/backend/src/app/api/activities/[id]/join/route.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import { getServerSession } from "@/lib/get-session";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// POST /api/activities/:id/join - Join an activity
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await getServerSession();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Musíte byť prihlásený" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const activity = await prisma.activity.findUnique({
|
||||
where: { id: id },
|
||||
include: {
|
||||
participations: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!activity) {
|
||||
return NextResponse.json(
|
||||
{ error: "Aktivita nenájdená" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (activity.status !== "OPEN") {
|
||||
return NextResponse.json(
|
||||
{ error: "Aktivita nie je otvorená pre nových účastníkov" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (activity.currentParticipants >= activity.maxParticipants) {
|
||||
return NextResponse.json(
|
||||
{ error: "Aktivita je už naplnená" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user is already participating
|
||||
const existingParticipation = await prisma.participation.findUnique({
|
||||
where: {
|
||||
userId_activityId: {
|
||||
userId: session.user.id,
|
||||
activityId: id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingParticipation) {
|
||||
return NextResponse.json(
|
||||
{ error: "Už ste prihlásený na túto aktivitu" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create participation
|
||||
const participation = await prisma.participation.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
activityId: id,
|
||||
status: "confirmed",
|
||||
},
|
||||
});
|
||||
|
||||
// Update activity participant count
|
||||
const updatedActivity = await prisma.activity.update({
|
||||
where: { id: id },
|
||||
data: {
|
||||
currentParticipants: {
|
||||
increment: 1,
|
||||
},
|
||||
status:
|
||||
activity.currentParticipants + 1 >= activity.maxParticipants
|
||||
? "FULL"
|
||||
: "OPEN",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ participation, activity: updatedActivity });
|
||||
} catch (error) {
|
||||
console.error("Error joining activity:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Chyba pri prihlásení na aktivitu" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/activities/:id/join - Leave an activity
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await getServerSession();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Musíte byť prihlásený" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const activity = await prisma.activity.findUnique({
|
||||
where: { id: id },
|
||||
});
|
||||
|
||||
if (!activity) {
|
||||
return NextResponse.json(
|
||||
{ error: "Aktivita nenájdená" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (activity.organizerId === session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Organizátor nemôže opustiť vlastnú aktivitu" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Delete participation
|
||||
await prisma.participation.delete({
|
||||
where: {
|
||||
userId_activityId: {
|
||||
userId: session.user.id,
|
||||
activityId: id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Update activity participant count
|
||||
const updatedActivity = await prisma.activity.update({
|
||||
where: { id: id },
|
||||
data: {
|
||||
currentParticipants: {
|
||||
decrement: 1,
|
||||
},
|
||||
status: "OPEN",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ activity: updatedActivity });
|
||||
} catch (error) {
|
||||
console.error("Error leaving activity:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Chyba pri opustení aktivity" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
165
apps/backend/src/app/api/activities/[id]/route.ts
Normal file
165
apps/backend/src/app/api/activities/[id]/route.ts
Normal file
@ -0,0 +1,165 @@
|
||||
import { getServerSession } from "@/lib/get-session";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// GET /api/activities/:id - Get activity by ID
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const activity = await prisma.activity.findUnique({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
include: {
|
||||
venue: true,
|
||||
organizer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
skillLevel: true,
|
||||
},
|
||||
},
|
||||
participations: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
skillLevel: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!activity) {
|
||||
return NextResponse.json(
|
||||
{ error: "Aktivita nenájdená" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(activity);
|
||||
} catch (error) {
|
||||
console.error("Error fetching activity:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Chyba pri načítaní aktivity" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/activities/:id - Update activity
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await getServerSession();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Musíte byť prihlásený" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const activity = await prisma.activity.findUnique({
|
||||
where: { id: id },
|
||||
});
|
||||
|
||||
if (!activity) {
|
||||
return NextResponse.json(
|
||||
{ error: "Aktivita nenájdená" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (activity.organizerId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Nemáte oprávnenie upraviť túto aktivitu" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const updatedActivity = await prisma.activity.update({
|
||||
where: { id: id },
|
||||
data: body,
|
||||
include: {
|
||||
venue: true,
|
||||
organizer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updatedActivity);
|
||||
} catch (error) {
|
||||
console.error("Error updating activity:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Chyba pri aktualizácii aktivity" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/activities/:id - Delete activity
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = await getServerSession();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Musíte byť prihlásený" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const activity = await prisma.activity.findUnique({
|
||||
where: { id: id },
|
||||
});
|
||||
|
||||
if (!activity) {
|
||||
return NextResponse.json(
|
||||
{ error: "Aktivita nenájdená" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (activity.organizerId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Nemáte oprávnenie zmazať túto aktivitu" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.activity.delete({
|
||||
where: { id: id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "Aktivita bola zmazaná" });
|
||||
} catch (error) {
|
||||
console.error("Error deleting activity:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Chyba pri mazaní aktivity" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
146
apps/backend/src/app/api/activities/route.ts
Normal file
146
apps/backend/src/app/api/activities/route.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { getServerSession } from "@/lib/get-session";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { z } from "zod";
|
||||
|
||||
// Validation schema
|
||||
const activitySchema = z.object({
|
||||
title: z.string().min(3).max(100),
|
||||
description: z.string().optional(),
|
||||
sportType: z.enum([
|
||||
"FOOTBALL",
|
||||
"BASKETBALL",
|
||||
"TENNIS",
|
||||
"VOLLEYBALL",
|
||||
"BADMINTON",
|
||||
"TABLE_TENNIS",
|
||||
"RUNNING",
|
||||
"CYCLING",
|
||||
"SWIMMING",
|
||||
"GYM",
|
||||
"OTHER",
|
||||
]),
|
||||
skillLevel: z.enum(["BEGINNER", "INTERMEDIATE", "ADVANCED", "EXPERT"]),
|
||||
date: z.string().datetime(),
|
||||
duration: z.number().min(15).max(480),
|
||||
maxParticipants: z.number().min(2).max(50),
|
||||
venueId: z.string(),
|
||||
isPublic: z.boolean().default(true),
|
||||
});
|
||||
|
||||
// GET /api/activities - Get all activities
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const sportType = searchParams.get("sportType");
|
||||
const city = searchParams.get("city");
|
||||
const status = searchParams.get("status") || "OPEN";
|
||||
|
||||
const activities = await prisma.activity.findMany({
|
||||
where: {
|
||||
...(sportType && { sportType: sportType as any }),
|
||||
status: status as any,
|
||||
...(city && {
|
||||
venue: {
|
||||
city: city,
|
||||
},
|
||||
}),
|
||||
date: {
|
||||
gte: new Date(),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
venue: true,
|
||||
organizer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
participations: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
date: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(activities);
|
||||
} catch (error) {
|
||||
console.error("Error fetching activities:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Chyba pri načítaní aktivít" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/activities - Create new activity
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Musíte byť prihlásený" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const validatedData = activitySchema.parse(body);
|
||||
|
||||
const activity = await prisma.activity.create({
|
||||
data: {
|
||||
...validatedData,
|
||||
date: new Date(validatedData.date),
|
||||
organizerId: session.user.id,
|
||||
currentParticipants: 1,
|
||||
},
|
||||
include: {
|
||||
venue: true,
|
||||
organizer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Automatically add organizer as participant
|
||||
await prisma.participation.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
activityId: activity.id,
|
||||
status: "confirmed",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(activity, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Neplatné údaje", details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.error("Error creating activity:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Chyba pri vytváraní aktivity" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
4
apps/backend/src/app/api/auth/[...all]/route.ts
Normal file
4
apps/backend/src/app/api/auth/[...all]/route.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth);
|
||||
98
apps/backend/src/app/api/venues/route.ts
Normal file
98
apps/backend/src/app/api/venues/route.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { getServerSession } from "@/lib/get-session";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { z } from "zod";
|
||||
|
||||
// Validation schema
|
||||
const venueSchema = z.object({
|
||||
name: z.string().min(3).max(100),
|
||||
description: z.string().optional(),
|
||||
address: z.string().min(5),
|
||||
city: z.string().min(2),
|
||||
latitude: z.number().optional(),
|
||||
longitude: z.number().optional(),
|
||||
sportTypes: z.array(z.string()),
|
||||
amenities: z.array(z.string()).optional(),
|
||||
priceRange: z.string().optional(),
|
||||
phone: z.string().optional(),
|
||||
website: z.string().url().optional(),
|
||||
image: z.string().url().optional(),
|
||||
});
|
||||
|
||||
// GET /api/venues - Get all venues
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const city = searchParams.get("city");
|
||||
const sportType = searchParams.get("sportType");
|
||||
|
||||
const venues = await prisma.venue.findMany({
|
||||
where: {
|
||||
...(city && { city }),
|
||||
...(sportType && {
|
||||
sportTypes: {
|
||||
has: sportType as any,
|
||||
},
|
||||
}),
|
||||
},
|
||||
include: {
|
||||
reviews: {
|
||||
select: {
|
||||
rating: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
activities: true,
|
||||
reviews: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate average rating for each venue
|
||||
const venuesWithRating = venues.map((venue) => ({
|
||||
...venue,
|
||||
averageRating:
|
||||
venue.reviews.length > 0
|
||||
? venue.reviews.reduce((sum, r) => sum + r.rating, 0) /
|
||||
venue.reviews.length
|
||||
: 0,
|
||||
}));
|
||||
|
||||
return NextResponse.json(venuesWithRating);
|
||||
} catch (error) {
|
||||
console.error("Error fetching venues:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Chyba pri načítaní športovísk" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/venues - Create new venue (admin only for now)
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const validatedData = venueSchema.parse(body);
|
||||
|
||||
const venue = await prisma.venue.create({
|
||||
data: validatedData as any,
|
||||
});
|
||||
|
||||
return NextResponse.json(venue, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Neplatné údaje", details: error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.error("Error creating venue:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Chyba pri vytváraní športoviska" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
7
apps/backend/src/lib/auth-client.ts
Normal file
7
apps/backend/src/lib/auth-client.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
|
||||
});
|
||||
|
||||
export const { signIn, signUp, signOut, useSession } = authClient;
|
||||
37
apps/backend/src/lib/auth.ts
Normal file
37
apps/backend/src/lib/auth.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: "postgresql",
|
||||
}),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: false,
|
||||
},
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID || "",
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
|
||||
enabled: !!process.env.GOOGLE_CLIENT_ID && !!process.env.GOOGLE_CLIENT_SECRET,
|
||||
},
|
||||
apple: {
|
||||
clientId: process.env.APPLE_CLIENT_ID || "",
|
||||
clientSecret: process.env.APPLE_CLIENT_SECRET || "",
|
||||
enabled: !!process.env.APPLE_CLIENT_ID && !!process.env.APPLE_CLIENT_SECRET,
|
||||
},
|
||||
},
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 30, // 30 days
|
||||
updateAge: 60 * 60 * 24, // 1 day
|
||||
},
|
||||
advanced: {
|
||||
generateId: () => crypto.randomUUID(),
|
||||
},
|
||||
trustedOrigins: ["http://localhost:3000"],
|
||||
});
|
||||
|
||||
export type Session = typeof auth.$Infer.Session.session;
|
||||
export type User = typeof auth.$Infer.Session.user;
|
||||
|
||||
13
apps/backend/src/lib/get-session.ts
Normal file
13
apps/backend/src/lib/get-session.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { auth } from "./auth";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export async function getServerSession() {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
return session;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
13
apps/backend/src/lib/prisma.ts
Normal file
13
apps/backend/src/lib/prisma.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: ['query', 'error', 'warn'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||
42
apps/backend/tsconfig.json
Normal file
42
apps/backend/tsconfig.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"target": "ES2017",
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
6
apps/frontend/.env.example
Normal file
6
apps/frontend/.env.example
Normal file
@ -0,0 +1,6 @@
|
||||
# Frontend Environment Variables
|
||||
|
||||
# API URL - Backend endpoint
|
||||
# For Docker: http://localhost:3001
|
||||
# For production: https://your-backend-domain.com
|
||||
NEXT_PUBLIC_API_URL="http://localhost:3001"
|
||||
46
apps/frontend/Dockerfile
Normal file
46
apps/frontend/Dockerfile
Normal file
@ -0,0 +1,46 @@
|
||||
# Unified Dockerfile for Frontend (Development & Production)
|
||||
# Usage:
|
||||
# Development: docker-compose up (uses 'development' target)
|
||||
# Production: docker build --target production -t sportbuddy-frontend .
|
||||
|
||||
FROM node:alpine AS base
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Stage 1: Install dependencies
|
||||
FROM base AS deps
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Stage 2: Development (hot reload with volume mounts)
|
||||
FROM base AS development
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
# Stage 3: Build for production
|
||||
FROM base AS builder
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
# Stage 4: Production runtime
|
||||
FROM base AS production
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
9
apps/frontend/next.config.mjs
Normal file
9
apps/frontend/next.config.mjs
Normal file
@ -0,0 +1,9 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
webpackBuildWorker: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
8049
apps/frontend/package-lock.json
generated
Normal file
8049
apps/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
apps/frontend/package.json
Normal file
36
apps/frontend/package.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@sportbuddy/frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "latest",
|
||||
"@tailwindcss/typography": "latest",
|
||||
"better-auth": "latest",
|
||||
"class-variance-authority": "latest",
|
||||
"date-fns": "latest",
|
||||
"lucide-react": "latest",
|
||||
"next": "latest",
|
||||
"next-pwa": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"zod": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/third-parties": "latest",
|
||||
"@types/node": "latest",
|
||||
"@types/react": "latest",
|
||||
"@types/react-dom": "latest",
|
||||
"autoprefixer": "latest",
|
||||
"eslint": "latest",
|
||||
"eslint-config-next": "latest",
|
||||
"postcss": "latest",
|
||||
"tailwindcss": "latest",
|
||||
"typescript": "latest"
|
||||
}
|
||||
}
|
||||
8
apps/frontend/postcss.config.mjs
Normal file
8
apps/frontend/postcss.config.mjs
Normal file
@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
4
apps/frontend/public/.icons-placeholder
Normal file
4
apps/frontend/public/.icons-placeholder
Normal file
@ -0,0 +1,4 @@
|
||||
# PWA Icons Placeholder
|
||||
# Generate actual icons using a tool like https://www.pwabuilder.com/imageGenerator
|
||||
|
||||
# For now, create simple placeholder SVG
|
||||
BIN
apps/frontend/public/icon-144x144.png
Normal file
BIN
apps/frontend/public/icon-144x144.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
15
apps/frontend/public/icon-144x144.svg
Normal file
15
apps/frontend/public/icon-144x144.svg
Normal file
@ -0,0 +1,15 @@
|
||||
<svg width="144" height="144" viewBox="0 0 144 144" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0078d4;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#106ebe;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="144" height="144" rx="24" fill="url(#grad)"/>
|
||||
<g transform="translate(36, 30)">
|
||||
<path d="M36 6L6 21L36 36L66 21L36 6Z" fill="white" fill-opacity="0.9"/>
|
||||
<path d="M6 51L36 66L66 51" stroke="white" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<path d="M6 36L36 51L66 36" stroke="white" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<circle cx="36" cy="21" r="4.5" fill="white"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 825 B |
84
apps/frontend/public/manifest.json
Normal file
84
apps/frontend/public/manifest.json
Normal file
@ -0,0 +1,84 @@
|
||||
{
|
||||
"name": "SportBuddy",
|
||||
"short_name": "SportBuddy",
|
||||
"description": "Aplikácia pre športových nadšencov na hľadanie spoluhráčov a organizáciu športových aktivít",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f3f3f3",
|
||||
"theme_color": "#0078d4",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
}
|
||||
],
|
||||
"categories": ["sports", "lifestyle", "social"],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/screenshot1.png",
|
||||
"type": "image/png",
|
||||
"sizes": "540x720"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Nájsť aktivity",
|
||||
"short_name": "Aktivity",
|
||||
"description": "Vyhľadať športové aktivity",
|
||||
"url": "/activities",
|
||||
"icons": [{ "src": "/icon-192x192.png", "sizes": "192x192" }]
|
||||
},
|
||||
{
|
||||
"name": "Moje aktivity",
|
||||
"short_name": "Moje",
|
||||
"description": "Zobraziť moje aktivity",
|
||||
"url": "/my-activities",
|
||||
"icons": [{ "src": "/icon-192x192.png", "sizes": "192x192" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
61
apps/frontend/public/sw.js
Normal file
61
apps/frontend/public/sw.js
Normal file
@ -0,0 +1,61 @@
|
||||
// Service Worker for PWA
|
||||
const CACHE_NAME = 'sportbuddy-v1';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/manifest.json',
|
||||
'/icon-192x192.png',
|
||||
'/icon-512x512.png',
|
||||
];
|
||||
|
||||
// Install service worker
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(urlsToCache);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch from cache
|
||||
self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((response) => {
|
||||
// Cache hit - return response
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return fetch(event.request).then((response) => {
|
||||
// Check if valid response
|
||||
if (!response || response.status !== 200 || response.type !== 'basic') {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Clone the response
|
||||
const responseToCache = response.clone();
|
||||
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseToCache);
|
||||
});
|
||||
|
||||
return response;
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Activate and clean old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
const cacheWhitelist = [CACHE_NAME];
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames.map((cacheName) => {
|
||||
if (cacheWhitelist.indexOf(cacheName) === -1) {
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
157
apps/frontend/src/app/auth/signin/page.tsx
Normal file
157
apps/frontend/src/app/auth/signin/page.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { signIn } from '@/lib/auth-client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
|
||||
export default function SignInPage() {
|
||||
const router = useRouter();
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setErrors({});
|
||||
|
||||
// Basic validation
|
||||
if (!formData.email || !formData.password) {
|
||||
setErrors({ general: 'Všetky polia sú povinné' });
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await signIn.email({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
router.push('/dashboard');
|
||||
router.refresh();
|
||||
},
|
||||
onError: (ctx) => {
|
||||
setErrors({ general: ctx.error.message || 'Nesprávny email alebo heslo' });
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
setErrors({ general: 'Nastala chyba. Skúste to znova.' });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Back Button */}
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 text-[color:var(--fluent-text-secondary)] hover:text-[color:var(--fluent-accent)] transition-colors duration-150 mb-6 group"
|
||||
>
|
||||
<svg className="w-5 h-5 transition-transform duration-150 group-hover:-translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span className="text-[15px] font-medium">Späť na hlavnú stránku</span>
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center mb-10">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 gradient-primary rounded-xl mb-5" style={{ boxShadow: 'var(--shadow-md)' }}>
|
||||
<svg className="w-10 h-10 text-[color:var(--fluent-text-secondary)]" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2L2 7L12 12L22 7L12 2Z" fill="currentColor" fillOpacity="0.9"/>
|
||||
<path d="M2 17L12 22L22 17" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M2 12L12 17L22 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<circle cx="12" cy="7" r="1.5" fill="rgba(250, 250, 250, 0.95)"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-[color:var(--fluent-text)] mb-3">
|
||||
Vitajte späť!
|
||||
</h1>
|
||||
<p className="text-lg text-[color:var(--fluent-text-secondary)]">
|
||||
Prihláste sa do svojho účtu
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="acrylic-strong border border-[color:var(--fluent-border)] rounded-xl p-10 reveal-effect transition-all duration-200" style={{ boxShadow: 'var(--shadow-lg)' }}>
|
||||
{errors.general && (
|
||||
<div className="mb-6 p-4 bg-red-50/80 border-2 border-red-200 rounded-lg text-red-600 text-base font-medium dark:bg-red-900/30 dark:border-red-800 dark:text-red-400">
|
||||
{errors.general}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-base font-semibold text-[color:var(--fluent-text)] mb-2">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder="jan.novak@email.com"
|
||||
disabled={isLoading}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-base font-semibold text-[color:var(--fluent-text)] mb-2">
|
||||
Heslo
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-base">
|
||||
<label className="flex items-center cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-5 h-5 text-blue-600 border-2 border-[color:var(--fluent-border-strong)] rounded-full focus:ring-2 focus:ring-blue-500 focus:ring-offset-0 transition-all duration-200 cursor-pointer hover:border-blue-500"
|
||||
/>
|
||||
<span className="ml-2.5 text-[color:var(--fluent-text-secondary)] font-medium group-hover:text-[color:var(--fluent-text)] transition-colors">Zapamätať si ma</span>
|
||||
</label>
|
||||
<Link href="/auth/forgot-password" className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 font-semibold hover:underline transition-colors">
|
||||
Zabudli ste heslo?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Prihlasovanie...' : 'Prihlásiť sa'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-base text-[color:var(--fluent-text-secondary)]">
|
||||
Nemáte účet?{' '}
|
||||
<Link href="/auth/signup" className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 font-semibold hover:underline">
|
||||
Zaregistrujte sa
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
243
apps/frontend/src/app/auth/signup/page.tsx
Normal file
243
apps/frontend/src/app/auth/signup/page.tsx
Normal file
@ -0,0 +1,243 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { signUp, signIn } from '@/lib/auth-client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
|
||||
export default function SignUpPage() {
|
||||
const router = useRouter();
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
// Name validation
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Meno je povinné';
|
||||
} else if (formData.name.length < 2) {
|
||||
newErrors.name = 'Meno musí mať aspoň 2 znaky';
|
||||
}
|
||||
|
||||
// Email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!formData.email) {
|
||||
newErrors.email = 'Email je povinný';
|
||||
} else if (!emailRegex.test(formData.email)) {
|
||||
newErrors.email = 'Neplatný formát emailu';
|
||||
}
|
||||
|
||||
// Password validation
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'Heslo je povinné';
|
||||
} else if (formData.password.length < 8) {
|
||||
newErrors.password = 'Heslo musí mať aspoň 8 znakov';
|
||||
} else if (!/(?=.*[a-z])/.test(formData.password)) {
|
||||
newErrors.password = 'Heslo musí obsahovať malé písmeno';
|
||||
} else if (!/(?=.*[A-Z])/.test(formData.password)) {
|
||||
newErrors.password = 'Heslo musí obsahovať veľké písmeno';
|
||||
} else if (!/(?=.*\d)/.test(formData.password)) {
|
||||
newErrors.password = 'Heslo musí obsahovať číslo';
|
||||
}
|
||||
|
||||
// Confirm password validation
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Heslá sa nezhodujú';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
// Register user with Better Auth
|
||||
await signUp.email({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
name: formData.name,
|
||||
}, {
|
||||
onSuccess: async () => {
|
||||
// Auto sign in after registration
|
||||
await signIn.email({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
router.push('/dashboard');
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => {
|
||||
// Registration successful but login failed
|
||||
router.push('/auth/signin?registered=true');
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: (ctx) => {
|
||||
setErrors({ general: ctx.error.message || 'Registrácia zlyhala' });
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
setErrors({ general: 'Nastala chyba. Skúste to znova.' });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Back Button */}
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 text-[color:var(--fluent-text-secondary)] hover:text-[color:var(--fluent-accent)] transition-colors duration-150 mb-6 group"
|
||||
>
|
||||
<svg className="w-5 h-5 transition-transform duration-150 group-hover:-translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span className="text-[15px] font-medium">Späť na hlavnú stránku</span>
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center mb-10">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 gradient-primary rounded-xl mb-5" style={{ boxShadow: 'var(--shadow-md)' }}>
|
||||
<svg className="w-10 h-10 text-[color:var(--fluent-text-secondary)]" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2L2 7L12 12L22 7L12 2Z" fill="currentColor" fillOpacity="0.9"/>
|
||||
<path d="M2 17L12 22L22 17" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M2 12L12 17L22 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<circle cx="12" cy="7" r="1.5" fill="rgba(250, 250, 250, 0.95)"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-[color:var(--fluent-text)] mb-3">
|
||||
Vytvorte si účet
|
||||
</h1>
|
||||
<p className="text-lg text-[color:var(--fluent-text-secondary)]">
|
||||
Pripojte sa k športovej komunite
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="acrylic-strong border border-[color:var(--fluent-border)] rounded-xl p-10 reveal-effect transition-all duration-200" style={{ boxShadow: 'var(--shadow-lg)' }}>
|
||||
{errors.general && (
|
||||
<div className="mb-6 p-4 bg-red-50/80 border-2 border-red-200 rounded-lg text-red-600 text-base font-medium dark:bg-red-900/30 dark:border-red-800 dark:text-red-400">
|
||||
{errors.general}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-base font-semibold text-[color:var(--fluent-text)] mb-2">
|
||||
Meno
|
||||
</label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Ján Novák"
|
||||
className={errors.name ? 'border-red-500' : ''}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400 font-medium">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-base font-semibold text-[color:var(--fluent-text)] mb-2">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder="jan.novak@email.com"
|
||||
className={errors.email ? 'border-red-500' : ''}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400 font-medium">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-base font-semibold text-[color:var(--fluent-text)] mb-2">
|
||||
Heslo
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
className={errors.password ? 'border-red-500' : ''}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400 font-medium">{errors.password}</p>
|
||||
)}
|
||||
<p className="mt-2 text-sm text-[color:var(--fluent-text-tertiary)]">
|
||||
Min. 8 znakov, veľké písmeno, malé písmeno a číslo
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-base font-semibold text-[color:var(--fluent-text)] mb-2">
|
||||
Potvrďte heslo
|
||||
</label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
className={errors.confirmPassword ? 'border-red-500' : ''}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400 font-medium">{errors.confirmPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Vytváram účet...' : 'Vytvoriť účet'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-base text-[color:var(--fluent-text-secondary)]">
|
||||
Už máte účet?{' '}
|
||||
<Link href="/auth/signin" className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 font-semibold hover:underline">
|
||||
Prihláste sa
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
178
apps/frontend/src/app/dashboard/page.tsx
Normal file
178
apps/frontend/src/app/dashboard/page.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data: session, isPending } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !session) {
|
||||
router.push('/auth/signin');
|
||||
}
|
||||
}, [session, isPending, router]);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p className="text-[color:var(--fluent-text-secondary)]">Načítavam...</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen" style={{ backgroundColor: 'var(--fluent-bg)' }}>
|
||||
{/* Hero Section */}
|
||||
<div className="px-4 sm:px-6 lg:px-8 pt-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="relative overflow-hidden gradient-cta py-12 px-8 rounded-2xl text-[color:var(--fluent-text-secondary)] dark:text-[color:var(--fluent-text)]" style={{ boxShadow: 'var(--shadow-lg)' }}>
|
||||
<div className="relative z-10">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-3 drop-shadow-lg">
|
||||
Ahoj, {session.user?.name}! 👋
|
||||
</h1>
|
||||
<p className="text-xl opacity-90 drop-shadow">
|
||||
Vitajte vo vašom športovom dashboarde
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="w-16 h-16 gradient-feature-1 rounded-xl mb-4 flex items-center justify-center" style={{ boxShadow: 'var(--shadow-sm)' }}>
|
||||
<svg className="w-8 h-8 text-[color:var(--fluent-text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle>Moje aktivity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-bold text-[color:var(--fluent-text)]">0</p>
|
||||
<p className="text-sm text-[color:var(--fluent-text-secondary)] mt-1">Vytvorené aktivity</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="w-16 h-16 gradient-feature-2 rounded-xl mb-4 flex items-center justify-center" style={{ boxShadow: 'var(--shadow-sm)' }}>
|
||||
<svg className="w-8 h-8 text-[color:var(--fluent-text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle>Prihlásený na</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-bold text-[color:var(--fluent-text)]">0</p>
|
||||
<p className="text-sm text-[color:var(--fluent-text-secondary)] mt-1">Nadchádzajúcich aktivít</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="w-16 h-16 gradient-feature-3 rounded-xl mb-4 flex items-center justify-center" style={{ boxShadow: 'var(--shadow-sm)' }}>
|
||||
<svg className="w-8 h-8 text-[color:var(--fluent-text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle>Hodnotenie</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-bold text-[color:var(--fluent-text)]">-</p>
|
||||
<p className="text-sm text-[color:var(--fluent-text-secondary)] mt-1">Zatiaľ žiadne hodnotenia</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mb-12">
|
||||
<h2 className="text-2xl font-bold text-[color:var(--fluent-text)] mb-6">
|
||||
Rýchle akcie
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Link href="/activities/create">
|
||||
<Card hover className="h-full cursor-pointer">
|
||||
<CardContent className="flex items-center gap-4 p-6">
|
||||
<div className="w-16 h-16 gradient-primary rounded-xl flex items-center justify-center flex-shrink-0" style={{ boxShadow: 'var(--shadow-md)' }}>
|
||||
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-[color:var(--fluent-text)] mb-1">
|
||||
Vytvoriť aktivitu
|
||||
</h3>
|
||||
<p className="text-sm text-[color:var(--fluent-text-secondary)]">
|
||||
Zorganizujte novú športovú aktivitu
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/activities">
|
||||
<Card hover className="h-full cursor-pointer">
|
||||
<CardContent className="flex items-center gap-4 p-6">
|
||||
<div className="w-16 h-16 gradient-feature-2 rounded-xl flex items-center justify-center flex-shrink-0" style={{ boxShadow: 'var(--shadow-md)' }}>
|
||||
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-[color:var(--fluent-text)] mb-1">
|
||||
Hľadať aktivity
|
||||
</h3>
|
||||
<p className="text-sm text-[color:var(--fluent-text-secondary)]">
|
||||
Nájdite si spoluhráčov vo vašom meste
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
<Card>
|
||||
<CardContent className="text-center py-16">
|
||||
<div className="w-20 h-20 bg-[color:var(--fluent-surface-secondary)] rounded-full mx-auto mb-6 flex items-center justify-center" style={{ boxShadow: 'var(--shadow-sm)' }}>
|
||||
<svg className="w-10 h-10 text-[color:var(--fluent-text-tertiary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-[color:var(--fluent-text)] mb-2">
|
||||
Zatiaľ žiadne aktivity
|
||||
</h3>
|
||||
<p className="text-[color:var(--fluent-text-secondary)] mb-6 max-w-md mx-auto">
|
||||
Začnite vytvorením novej aktivity alebo sa pripojte k existujúcim aktivitám vo vašom okolí.
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Link href="/activities/create">
|
||||
<Button size="lg">Vytvoriť aktivitu</Button>
|
||||
</Link>
|
||||
<Link href="/activities">
|
||||
<Button variant="secondary" size="lg">Prehliadať aktivity</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
313
apps/frontend/src/app/globals.css
Normal file
313
apps/frontend/src/app/globals.css
Normal file
@ -0,0 +1,313 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
/* Windows 11 Light Theme - Clean, bright, airy with transparency */
|
||||
--fluent-bg: #f3f3f3; /* light neutral background */
|
||||
--fluent-surface: rgba(
|
||||
254,
|
||||
254,
|
||||
254,
|
||||
0.25
|
||||
); /* ultra translucent for stronger glass effect */
|
||||
--fluent-surface-secondary: rgba(249, 249, 249, 0.3); /* subtle variation */
|
||||
--fluent-accent: #0067c0; /* Windows 11 blue accent */
|
||||
--fluent-accent-hover: #005a9e; /* darker accent for hover */
|
||||
--fluent-accent-light: rgba(0, 103, 192, 0.08); /* subtle accent tint */
|
||||
--fluent-text: #1f1f1f; /* primary text - soft black */
|
||||
--fluent-text-secondary: #605e5c; /* secondary text - warm gray */
|
||||
--fluent-text-tertiary: #8a8886; /* tertiary text - lighter gray */
|
||||
--fluent-border: rgba(230, 230, 230, 0.4); /* more translucent borders */
|
||||
--fluent-border-strong: rgba(209, 209, 209, 0.6); /* stronger borders */
|
||||
--fluent-divider: rgba(
|
||||
220,
|
||||
220,
|
||||
220,
|
||||
0.35
|
||||
); /* more translucent divider lines */
|
||||
|
||||
/* Windows 11 Light Gradients - Subtle, sophisticated */
|
||||
--gradient-primary-start: #0078d4; /* MS blue */
|
||||
--gradient-primary-end: #106ebe; /* deeper blue */
|
||||
--gradient-cta-start: #0086f0; /* vibrant blue */
|
||||
--gradient-cta-mid: #1490df; /* cyan blue */
|
||||
--gradient-cta-end: #00b7c3; /* aqua */
|
||||
--gradient-feature-1-start: #0078d4; /* blue */
|
||||
--gradient-feature-1-end: #00bcf2; /* cyan */
|
||||
--gradient-feature-2-start: #00cc6a; /* green */
|
||||
--gradient-feature-2-end: #10893e; /* forest green */
|
||||
--gradient-feature-3-start: #ffb900; /* gold */
|
||||
--gradient-feature-3-end: #ff8c00; /* orange */
|
||||
|
||||
/* Shadows - layered depth */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 2px 4px 0 rgba(0, 0, 0, 0.08), 0 4px 8px 0 rgba(0, 0, 0, 0.04);
|
||||
--shadow-lg: 0 4px 8px 0 rgba(0, 0, 0, 0.12), 0 8px 16px 0 rgba(0, 0, 0, 0.06);
|
||||
--shadow-xl: 0 8px 16px 0 rgba(0, 0, 0, 0.14),
|
||||
0 12px 24px 0 rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Windows 11 Dark Theme - Rich, deep, contrasty */
|
||||
--fluent-bg: #202020; /* dark neutral background */
|
||||
--fluent-surface: #2c2c2c; /* elevated surfaces */
|
||||
--fluent-surface-secondary: #282828; /* subtle variation */
|
||||
--fluent-accent: #60cdff; /* Windows 11 cyan accent */
|
||||
--fluent-accent-hover: #4db8e8; /* darker accent for hover */
|
||||
--fluent-accent-light: rgba(96, 205, 255, 0.15); /* subtle accent tint */
|
||||
--fluent-text: #fafafa; /* primary text - almost white but not pure */
|
||||
--fluent-text-secondary: #c8c8c8; /* secondary text - light gray */
|
||||
--fluent-text-tertiary: #9d9d9d; /* tertiary text - medium gray */
|
||||
--fluent-border: #3d3d3d; /* subtle borders */
|
||||
--fluent-border-strong: #505050; /* stronger borders */
|
||||
--fluent-divider: #3b3b3b; /* divider lines */
|
||||
|
||||
/* Windows 11 Dark Gradients - Vibrant, energetic */
|
||||
--gradient-primary-start: #4cc2ff; /* bright cyan */
|
||||
--gradient-primary-end: #0078d4; /* MS blue */
|
||||
--gradient-cta-start: #60cdff; /* cyan */
|
||||
--gradient-cta-mid: #4db8e8; /* blue cyan */
|
||||
--gradient-cta-end: #3aa0d1; /* deep cyan */
|
||||
--gradient-feature-1-start: #60cdff; /* cyan */
|
||||
--gradient-feature-1-end: #00b7c3; /* aqua */
|
||||
--gradient-feature-2-start: #6bcf7f; /* bright green */
|
||||
--gradient-feature-2-end: #00cc6a; /* green */
|
||||
--gradient-feature-3-start: #ffd666; /* bright gold */
|
||||
--gradient-feature-3-end: #ffb900; /* gold */
|
||||
|
||||
/* Shadows - stronger for dark mode */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 2px 4px 0 rgba(0, 0, 0, 0.4), 0 4px 8px 0 rgba(0, 0, 0, 0.2);
|
||||
--shadow-lg: 0 4px 8px 0 rgba(0, 0, 0, 0.5), 0 8px 16px 0 rgba(0, 0, 0, 0.3);
|
||||
--shadow-xl: 0 8px 16px 0 rgba(0, 0, 0, 0.6), 0 12px 24px 0 rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Enhanced Glass / Acrylic effects - iOS inspired */
|
||||
.acrylic {
|
||||
background: rgba(254, 254, 254, 0.2);
|
||||
backdrop-filter: blur(80px) saturate(200%) brightness(1.08);
|
||||
-webkit-backdrop-filter: blur(80px) saturate(200%) brightness(1.08);
|
||||
border: 1px solid rgba(250, 250, 250, 0.5);
|
||||
box-shadow: inset 0 1px 1px 0 rgba(254, 254, 254, 0.8),
|
||||
0 4px 12px 0 rgba(0, 0, 0, 0.08), 0 12px 32px 0 rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.dark .acrylic {
|
||||
background: rgba(44, 44, 44, 0.65);
|
||||
backdrop-filter: blur(80px) saturate(200%) brightness(0.95);
|
||||
-webkit-backdrop-filter: blur(80px) saturate(200%) brightness(0.95);
|
||||
border: 1px solid rgba(250, 250, 250, 0.1);
|
||||
box-shadow: inset 0 1px 1px 0 rgba(250, 250, 250, 0.05),
|
||||
0 2px 8px 0 rgba(0, 0, 0, 0.3), 0 8px 24px 0 rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.acrylic-strong {
|
||||
background: rgba(254, 254, 254, 0.35);
|
||||
backdrop-filter: blur(80px) saturate(200%) brightness(1.12);
|
||||
-webkit-backdrop-filter: blur(80px) saturate(200%) brightness(1.12);
|
||||
border: 1px solid rgba(250, 250, 250, 0.7);
|
||||
box-shadow: inset 0 1px 2px 0 rgba(254, 254, 254, 0.9),
|
||||
0 6px 16px 0 rgba(0, 0, 0, 0.1), 0 16px 48px 0 rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.dark .acrylic-strong {
|
||||
background: rgba(44, 44, 44, 0.85);
|
||||
backdrop-filter: blur(80px) saturate(200%) brightness(0.92);
|
||||
-webkit-backdrop-filter: blur(80px) saturate(200%) brightness(0.92);
|
||||
border: 1px solid rgba(250, 250, 250, 0.15);
|
||||
box-shadow: inset 0 1px 2px 0 rgba(250, 250, 250, 0.08),
|
||||
0 4px 12px 0 rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Mica material - subtle background texture */
|
||||
.mica {
|
||||
background-color: var(--fluent-surface);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.dark .mica {
|
||||
background-color: var(--fluent-surface);
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Gradient text */
|
||||
.gradient-text {
|
||||
background: linear-gradient(to right, rgb(37 99 235), rgb(147 51 234));
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* Animated gradient background */
|
||||
@keyframes gradient-shift {
|
||||
0%,
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-gradient {
|
||||
background-size: 200% 200%;
|
||||
animation: gradient-shift 3s ease infinite;
|
||||
}
|
||||
|
||||
/* Slide down animation for mobile menu */
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
.transition-smooth {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Hover glow effect - Fluent style with glass morphism */
|
||||
.hover-glow {
|
||||
transition: all 0.3s cubic-bezier(0.33, 0, 0.67, 1);
|
||||
}
|
||||
|
||||
.hover-glow:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
backdrop-filter: blur(25px) saturate(200%) brightness(1.1);
|
||||
-webkit-backdrop-filter: blur(25px) saturate(200%) brightness(1.1);
|
||||
}
|
||||
|
||||
.dark .hover-glow:hover {
|
||||
backdrop-filter: blur(25px) saturate(200%) brightness(1.15);
|
||||
-webkit-backdrop-filter: blur(25px) saturate(200%) brightness(1.15);
|
||||
}
|
||||
|
||||
/* Windows 11 reveal effect on hover - Enhanced for light mode */
|
||||
.reveal-effect {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.reveal-effect::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
rgba(250, 250, 250, 0.15) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.reveal-effect:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dark .reveal-effect::before {
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
rgba(250, 250, 250, 0.1) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--fluent-bg);
|
||||
color: var(--fluent-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Site navigation surface tuned to variables */
|
||||
.site-nav {
|
||||
background-color: var(--fluent-surface);
|
||||
border-bottom: 1px solid var(--fluent-border);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* Ensure links and headings inherit our text colors by default */
|
||||
a,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p,
|
||||
span,
|
||||
li {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Utility classes for using CSS variables with Tailwind where needed */
|
||||
.text-fluent {
|
||||
color: var(--fluent-text) !important;
|
||||
}
|
||||
.text-fluent-secondary {
|
||||
color: var(--fluent-text-secondary) !important;
|
||||
}
|
||||
.bg-fluent-surface {
|
||||
background-color: var(--fluent-surface) !important;
|
||||
}
|
||||
|
||||
/* Theme-aware gradient backgrounds */
|
||||
.gradient-primary {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--gradient-primary-start),
|
||||
var(--gradient-primary-end)
|
||||
);
|
||||
}
|
||||
|
||||
.gradient-cta {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--gradient-cta-start),
|
||||
var(--gradient-cta-mid),
|
||||
var(--gradient-cta-end)
|
||||
);
|
||||
}
|
||||
|
||||
.gradient-feature-1 {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--gradient-feature-1-start),
|
||||
var(--gradient-feature-1-end)
|
||||
);
|
||||
}
|
||||
|
||||
.gradient-feature-2 {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--gradient-feature-2-start),
|
||||
var(--gradient-feature-2-end)
|
||||
);
|
||||
}
|
||||
|
||||
.gradient-feature-3 {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--gradient-feature-3-start),
|
||||
var(--gradient-feature-3-end)
|
||||
);
|
||||
}
|
||||
44
apps/frontend/src/app/layout.tsx
Normal file
44
apps/frontend/src/app/layout.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
import TemplateWrapper from "@/components/TemplateWrapper";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "SportBuddy - Nájdi si spoluhráčov",
|
||||
description: "Aplikácia pre športových nadšencov na hľadanie spoluhráčov a organizáciu športových aktivít",
|
||||
manifest: "/manifest.json",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "SportBuddy",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
themeColor: "#0078d4",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="sk">
|
||||
<head>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icon-192x192.png" />
|
||||
</head>
|
||||
<body className="antialiased bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors duration-300">
|
||||
<ThemeProvider>
|
||||
<TemplateWrapper>
|
||||
{children}
|
||||
</TemplateWrapper>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
274
apps/frontend/src/app/page.tsx
Normal file
274
apps/frontend/src/app/page.tsx
Normal file
@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
export default function Home() {
|
||||
const [selectedSport, setSelectedSport] = useState<string | null>(null);
|
||||
|
||||
const sports = [
|
||||
{
|
||||
id: "FOOTBALL",
|
||||
name: "Futbal",
|
||||
icon: "⚽",
|
||||
color: "from-green-500 to-emerald-600",
|
||||
},
|
||||
{
|
||||
id: "BASKETBALL",
|
||||
name: "Basketbal",
|
||||
icon: "🏀",
|
||||
color: "from-orange-500 to-red-600",
|
||||
},
|
||||
{
|
||||
id: "TENNIS",
|
||||
name: "Tenis",
|
||||
icon: "🎾",
|
||||
color: "from-yellow-500 to-orange-500",
|
||||
},
|
||||
{
|
||||
id: "VOLLEYBALL",
|
||||
name: "Volejbal",
|
||||
icon: "🏐",
|
||||
color: "from-blue-500 to-cyan-600",
|
||||
},
|
||||
{
|
||||
id: "RUNNING",
|
||||
name: "Beh",
|
||||
icon: "🏃",
|
||||
color: "from-red-500 to-pink-600",
|
||||
},
|
||||
{
|
||||
id: "CYCLING",
|
||||
name: "Cyklistika",
|
||||
icon: "🚴",
|
||||
color: "from-purple-500 to-indigo-600",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col">
|
||||
{/* Sports Selection */}
|
||||
<div className="px-4 sm:px-6 lg:px-8 py-16">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h2 className="text-3xl font-semibold text-[color:var(--fluent-text)] text-center mb-10">
|
||||
Vyberte si váš šport
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
|
||||
{sports.map((sport) => (
|
||||
<button
|
||||
key={sport.id}
|
||||
onClick={() =>
|
||||
setSelectedSport(sport.id === selectedSport ? null : sport.id)
|
||||
}
|
||||
className={`
|
||||
acrylic
|
||||
border border-[color:var(--fluent-border)]
|
||||
rounded-xl p-8 text-center
|
||||
transition-all duration-200 ease-out
|
||||
reveal-effect
|
||||
${
|
||||
selectedSport === sport.id
|
||||
? "border-[color:var(--fluent-accent)] border-2 bg-[color:var(--fluent-accent-light)] scale-105"
|
||||
: "hover-glow hover:border-[color:var(--fluent-border-strong)]"
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
boxShadow:
|
||||
selectedSport === sport.id
|
||||
? "var(--shadow-lg)"
|
||||
: "var(--shadow-md)",
|
||||
}}
|
||||
>
|
||||
<div className="text-5xl mb-3">{sport.icon}</div>
|
||||
<div className="text-base font-semibold text-[color:var(--fluent-text)]">
|
||||
{sport.name}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedSport && (
|
||||
<div className="mt-6 text-center animate-fade-in">
|
||||
<Link href={`/activities?sport=${selectedSport}`}>
|
||||
<Button size="lg">
|
||||
Zobraziť {sports.find((s) => s.id === selectedSport)?.name}{" "}
|
||||
aktivity
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Section */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<h2 className="text-3xl font-semibold text-[color:var(--fluent-text)] text-center mb-10">
|
||||
Ako to funguje
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{/* Feature 1 */}
|
||||
<Card hover>
|
||||
<CardHeader>
|
||||
<div
|
||||
className="w-16 h-16 gradient-feature-1 rounded-xl mb-4 flex items-center justify-center transition-transform"
|
||||
style={{ boxShadow: "var(--shadow-sm)" }}
|
||||
>
|
||||
<svg
|
||||
className="w-8 h-8 text-[color:var(--fluent-text-secondary)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle>Nájdi spoluhráčov</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
Pripojte sa k existujúcim aktivitám alebo vytvorte vlastné
|
||||
podujatie a nájdite ľudí s podobnými záujmami.
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Feature 2 */}
|
||||
<Card hover>
|
||||
<CardHeader>
|
||||
<div
|
||||
className="w-16 h-16 gradient-feature-2 rounded-xl mb-4 flex items-center justify-center transition-transform"
|
||||
style={{ boxShadow: "var(--shadow-sm)" }}
|
||||
>
|
||||
<svg
|
||||
className="w-8 h-8 text-[color:var(--fluent-text-secondary)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle>Organizujte aktivity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
Jednoducho vytvárajte športové podujatia, nastavte čas, miesto a
|
||||
počet účastníkov.
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Feature 3 */}
|
||||
<Card hover>
|
||||
<CardHeader>
|
||||
<div
|
||||
className="w-16 h-16 gradient-feature-3 rounded-xl mb-4 flex items-center justify-center transition-transform"
|
||||
style={{ boxShadow: "var(--shadow-sm)" }}
|
||||
>
|
||||
<svg
|
||||
className="w-8 h-8 text-[color:var(--fluent-text-secondary)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle>Športoviská v meste</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
Prehľad všetkých dostupných športovísk vo vašom okolí s
|
||||
hodnoteniami a recenziami.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Section */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div
|
||||
className="relative overflow-hidden acrylic-strong rounded-xl p-12 text-center border-2 border-[color:var(--fluent-accent)]/30"
|
||||
style={{ boxShadow: "var(--shadow-xl)" }}
|
||||
>
|
||||
<div className="relative z-10">
|
||||
<h2 className="text-3xl font-bold mb-4 text-[color:var(--fluent-text)]">
|
||||
Pripravení začať?
|
||||
</h2>
|
||||
<p className="text-[color:var(--fluent-text-secondary)] mb-8 max-w-2xl mx-auto text-lg">
|
||||
Zaregistrujte sa ešte dnes a staňte sa súčasťou aktívnej športovej
|
||||
komunity.
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center flex-wrap">
|
||||
<Link href="/auth/signup">
|
||||
<Button
|
||||
size="lg"
|
||||
className="!bg-[color:var(--fluent-accent)] !text-white hover:!bg-[color:var(--fluent-accent-hover)] font-bold text-lg px-8 py-6 shadow-lg hover:shadow-xl transition-all"
|
||||
>
|
||||
Vytvoriť účet zadarmo
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/activities">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="border-2 border-[color:var(--fluent-border-strong)] !text-[color:var(--fluent-text)] hover:!bg-[color:var(--fluent-surface)] font-semibold text-lg px-8 py-6"
|
||||
>
|
||||
Preskúmať aktivity
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-[color:var(--fluent-divider)] mt-auto bg-[color:var(--fluent-surface-secondary)]">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-6">
|
||||
<p className="text-[color:var(--fluent-text-tertiary)] text-base text-center md:text-left">
|
||||
© 2025 SportBuddy. Všetky práva vyhradené.
|
||||
</p>
|
||||
<div className="flex gap-8">
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-base text-[color:var(--fluent-text-secondary)] hover:text-[color:var(--fluent-text)] transition-colors duration-150"
|
||||
>
|
||||
O nás
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="text-base text-[color:var(--fluent-text-secondary)] hover:text-[color:var(--fluent-text)] transition-colors duration-150"
|
||||
>
|
||||
Kontakt
|
||||
</Link>
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="text-base text-[color:var(--fluent-text-secondary)] hover:text-[color:var(--fluent-text)] transition-colors duration-150"
|
||||
>
|
||||
Ochrana údajov
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
0
apps/frontend/src/components/HtmlWrapper.tsx
Normal file
0
apps/frontend/src/components/HtmlWrapper.tsx
Normal file
308
apps/frontend/src/components/Navigation.tsx
Normal file
308
apps/frontend/src/components/Navigation.tsx
Normal file
@ -0,0 +1,308 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
import { useSession, signOut } from '@/lib/auth-client';
|
||||
|
||||
export default function Navigation() {
|
||||
const pathname = usePathname();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const { data: session } = useSession();
|
||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const navLinks = [
|
||||
{ href: '/', label: 'Domov' },
|
||||
{ href: '/activities', label: 'Aktivity' },
|
||||
{ href: '/venues', label: 'Športoviská' },
|
||||
{ href: '/my-activities', label: 'Moje aktivity' },
|
||||
];
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut();
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
// Close user menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) {
|
||||
setIsUserMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isUserMenuOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isUserMenuOpen]);
|
||||
|
||||
return (
|
||||
<nav className="sticky top-3 z-50 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto site-nav transition-colors duration-300 acrylic rounded-2xl px-4 sm:px-6 lg:px-8" style={{ boxShadow: 'var(--shadow-lg)' }}>
|
||||
<div className="flex justify-between items-center h-20 px-2">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center space-x-3 group">
|
||||
<div className="w-12 h-12 gradient-primary rounded-lg flex items-center justify-center group-hover:scale-105 transition-transform duration-200" style={{ boxShadow: 'var(--shadow-sm)' }}>
|
||||
<svg className="w-7 h-7 text-[color:var(--fluent-text-secondary)]" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2L2 7L12 12L22 7L12 2Z" fill="currentColor" fillOpacity="0.9"/>
|
||||
<path d="M2 17L12 22L22 17" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M2 12L12 17L22 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<circle cx="12" cy="7" r="1.5" fill="rgba(250, 250, 250, 0.95)"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-2xl font-semibold text-[color:var(--fluent-text)] hidden sm:block">
|
||||
SportBuddy
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center space-x-2">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`
|
||||
px-6 py-2.5 rounded-md font-medium text-lg
|
||||
hover-glow reveal-effect
|
||||
transition-all duration-200 ease-out
|
||||
${pathname === link.href
|
||||
? 'bg-[color:var(--fluent-accent-light)] text-[color:var(--fluent-accent)] font-semibold'
|
||||
: 'text-[color:var(--fluent-text-secondary)] hover:text-[color:var(--fluent-text)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Auth Buttons / User Menu */}
|
||||
<div className="hidden md:flex items-center space-x-4">
|
||||
<ThemeToggle />
|
||||
{session ? (
|
||||
<div className="relative" ref={userMenuRef}>
|
||||
<button
|
||||
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
|
||||
className="flex items-center space-x-3 px-4 py-2 rounded-lg hover-glow reveal-effect transition-all duration-200"
|
||||
style={{ boxShadow: 'var(--shadow-sm)' }}
|
||||
>
|
||||
<div className="w-10 h-10 gradient-primary rounded-full flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-[color:var(--fluent-text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-lg font-semibold text-[color:var(--fluent-text)]">{session.user?.name}</span>
|
||||
</button>
|
||||
|
||||
{isUserMenuOpen && (
|
||||
<div
|
||||
className="fixed left-0 right-0 mt-3 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 z-50"
|
||||
style={{
|
||||
top: 'calc(3rem + 5rem)', // top-3 + navbar height
|
||||
animation: 'slideDown 0.3s ease-out both'
|
||||
}}
|
||||
>
|
||||
<div className="acrylic-strong border border-[color:var(--fluent-border)] rounded-xl overflow-hidden reveal-effect w-56 ml-auto"
|
||||
style={{
|
||||
boxShadow: 'var(--shadow-xl)'
|
||||
}}>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="block px-4 py-3 text-base font-medium text-[color:var(--fluent-text)] hover:bg-[color:var(--fluent-surface-secondary)]/50 transition-all"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
<span>Dashboard</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="w-full text-left px-4 py-3 text-base font-medium text-red-600 dark:text-red-400 hover:bg-red-50/50 dark:hover:bg-red-950/20 transition-all"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
<span>Odhlásiť sa</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href="/auth/signin"
|
||||
className="px-6 py-2.5 text-lg font-semibold bg-blue-50/50 text-blue-600 hover:text-blue-700 hover:bg-blue-100/60 dark:bg-blue-950/40 dark:text-blue-400 dark:hover:text-blue-300 dark:hover:bg-blue-950/60 rounded-lg hover-glow reveal-effect transition-all duration-200"
|
||||
style={{
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)'
|
||||
}}
|
||||
>
|
||||
Prihlásiť sa
|
||||
</Link>
|
||||
<Link
|
||||
href="/auth/signup"
|
||||
className="px-6 py-2.5 text-lg bg-[color:var(--fluent-accent)]/90 text-[color:var(--fluent-text-secondary)] dark:text-[color:var(--fluent-text)] rounded-lg font-semibold hover:bg-[color:var(--fluent-accent-hover)] hover-glow reveal-effect transition-all duration-200"
|
||||
style={{
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)'
|
||||
}}
|
||||
>
|
||||
Registrovať
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Theme Toggle & Menu Button */}
|
||||
<div className="md:hidden flex items-center space-x-3">
|
||||
<ThemeToggle />
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className="p-2 rounded-md hover:bg-[color:var(--fluent-surface-secondary)] transition-all duration-200"
|
||||
>
|
||||
<div className="w-6 h-6 flex flex-col justify-center items-center space-y-1.5">
|
||||
<span
|
||||
className={`block w-6 h-0.5 bg-[color:var(--fluent-text)] transition-all duration-300 ${
|
||||
isMobileMenuOpen ? 'rotate-45 translate-y-2' : ''
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`block w-6 h-0.5 bg-[color:var(--fluent-text)] transition-all duration-300 ${
|
||||
isMobileMenuOpen ? 'opacity-0' : ''
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`block w-6 h-0.5 bg-[color:var(--fluent-text)] transition-all duration-300 ${
|
||||
isMobileMenuOpen ? '-rotate-45 -translate-y-2' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<div
|
||||
className={`md:hidden overflow-hidden transition-all duration-300 ease-out border-t border-[color:var(--fluent-divider)] ${
|
||||
isMobileMenuOpen ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0 border-t-0'
|
||||
}`}
|
||||
>
|
||||
<div className="pb-3 pt-2 space-y-1">
|
||||
{navLinks.map((link, index) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`
|
||||
block px-4 py-3 rounded-md font-medium text-base
|
||||
transition-all duration-150
|
||||
${pathname === link.href
|
||||
? 'bg-[color:var(--fluent-accent-light)] text-[color:var(--fluent-accent)]'
|
||||
: 'text-[color:var(--fluent-text-secondary)] hover:bg-[color:var(--fluent-surface-secondary)] hover:text-[color:var(--fluent-text)]'
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
animation: isMobileMenuOpen ? `slideDown 0.4s ease-out ${index * 0.08}s both` : 'none'
|
||||
}}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="pt-3 border-t border-[color:var(--fluent-divider)] space-y-3 mt-3">
|
||||
{session ? (
|
||||
<>
|
||||
<div className="flex items-center space-x-3 px-4 py-3">
|
||||
<div className="w-10 h-10 gradient-primary rounded-full flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-[color:var(--fluent-text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-base font-semibold text-[color:var(--fluent-text)]">{session.user?.name}</span>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="block px-4 py-3 text-base font-semibold bg-blue-50/50 text-blue-600 hover:text-blue-700 hover:bg-blue-100/60 dark:bg-blue-950/40 dark:text-blue-400 dark:hover:text-blue-300 dark:hover:bg-blue-950/60 rounded-lg hover-glow reveal-effect transition-all duration-200"
|
||||
style={{
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
animation: isMobileMenuOpen ? `slideDown 0.4s ease-out ${navLinks.length * 0.08 + 0.08}s both` : 'none'
|
||||
}}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
<span>Dashboard</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="w-full block px-4 py-3 text-base text-left font-semibold text-red-600 dark:text-red-400 hover:bg-red-50/50 dark:hover:bg-red-950/20 rounded-lg hover-glow reveal-effect transition-all duration-200"
|
||||
style={{
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
animation: isMobileMenuOpen ? `slideDown 0.4s ease-out ${navLinks.length * 0.08 + 0.16}s both` : 'none'
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
<span>Odhlásiť sa</span>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href="/auth/signin"
|
||||
className="block px-4 py-3 text-base font-semibold bg-blue-50/50 text-blue-600 hover:text-blue-700 hover:bg-blue-100/60 dark:bg-blue-950/40 dark:text-blue-400 dark:hover:text-blue-300 dark:hover:bg-blue-950/60 rounded-lg hover-glow reveal-effect transition-all duration-200 text-center"
|
||||
style={{
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
animation: isMobileMenuOpen ? `slideDown 0.4s ease-out ${navLinks.length * 0.08 + 0.08}s both` : 'none'
|
||||
}}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
Prihlásiť sa
|
||||
</Link>
|
||||
<Link
|
||||
href="/auth/signup"
|
||||
className="block px-4 py-3 text-base bg-[color:var(--fluent-accent)]/90 text-[color:var(--fluent-text-secondary)] dark:text-[color:var(--fluent-text)] rounded-lg text-center font-semibold hover:bg-[color:var(--fluent-accent-hover)] hover-glow reveal-effect transition-all duration-200"
|
||||
style={{
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
animation: isMobileMenuOpen ? `slideDown 0.4s ease-out ${navLinks.length * 0.08 + 0.16}s both` : 'none'
|
||||
}}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
Registrovať
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
31
apps/frontend/src/components/TemplateWrapper.tsx
Normal file
31
apps/frontend/src/components/TemplateWrapper.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import Navigation from '@/components/Navigation';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
|
||||
export default function TemplateWrapper({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const isAuthPage = pathname?.startsWith('/auth');
|
||||
|
||||
// For auth pages, just show ThemeToggle in corner
|
||||
if (isAuthPage) {
|
||||
return (
|
||||
<>
|
||||
<div className="fixed top-4 right-4 z-50">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// For all other pages, show full Navigation
|
||||
return (
|
||||
<>
|
||||
<Navigation />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
apps/frontend/src/components/ThemeToggle.tsx
Normal file
34
apps/frontend/src/components/ThemeToggle.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="p-2 rounded-lg acrylic w-9 h-9" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg acrylic hover-glow reveal-effect transition-all"
|
||||
aria-label="Prepnúť tému"
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<Moon className="w-5 h-5 text-[color:var(--fluent-text)]" />
|
||||
) : (
|
||||
<Sun className="w-5 h-5 text-[color:var(--fluent-text)]" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
44
apps/frontend/src/components/ui/Button.tsx
Normal file
44
apps/frontend/src/components/ui/Button.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
const baseStyles = 'font-semibold rounded-lg transition-all duration-150 ease-out focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variantStyles = {
|
||||
primary: 'bg-[color:var(--fluent-accent)] text-[color:var(--fluent-text-secondary)] dark:text-[color:var(--fluent-text)] hover:bg-[color:var(--fluent-accent-hover)] focus:ring-[color:var(--fluent-accent)] active:scale-[0.98]',
|
||||
secondary: 'bg-[color:var(--fluent-surface-secondary)] border border-[color:var(--fluent-border)] text-[color:var(--fluent-text)] hover:bg-[color:var(--fluent-border)] hover:border-[color:var(--fluent-border-strong)] focus:ring-[color:var(--fluent-border-strong)] active:scale-[0.98]',
|
||||
outline: 'border-2 border-[color:var(--fluent-accent)] text-[color:var(--fluent-accent)] hover:bg-[color:var(--fluent-accent-light)] focus:ring-[color:var(--fluent-accent)] active:scale-[0.98]',
|
||||
};
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'px-4 py-2 text-sm',
|
||||
md: 'px-5 py-2.5 text-base',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
const shadowStyles = {
|
||||
primary: 'shadow-[var(--shadow-sm)] hover:shadow-[var(--shadow-md)]',
|
||||
secondary: 'shadow-[var(--shadow-sm)] hover:shadow-[var(--shadow-md)]',
|
||||
outline: '',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${shadowStyles[variant as keyof typeof shadowStyles]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
80
apps/frontend/src/components/ui/Card.tsx
Normal file
80
apps/frontend/src/components/ui/Card.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
hover?: boolean;
|
||||
}
|
||||
|
||||
export const Card: React.FC<CardProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
hover = false
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
acrylic
|
||||
border border-[color:var(--fluent-border)]
|
||||
rounded-xl
|
||||
p-10
|
||||
transition-all duration-200 ease-out
|
||||
${hover ? 'cursor-pointer hover-glow reveal-effect hover:border-[color:var(--fluent-border-strong)]' : ''}
|
||||
${className}
|
||||
`}
|
||||
style={{
|
||||
boxShadow: 'var(--shadow-md)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CardHeaderProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CardHeader: React.FC<CardHeaderProps> = ({
|
||||
children,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div className={`mb-5 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CardTitleProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CardTitle: React.FC<CardTitleProps> = ({
|
||||
children,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<h3 className={`text-2xl font-semibold text-[color:var(--fluent-text)] ${className}`}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
};
|
||||
|
||||
interface CardContentProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CardContent: React.FC<CardContentProps> = ({
|
||||
children,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div className={`text-base text-[color:var(--fluent-text-secondary)] leading-relaxed ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
150
apps/frontend/src/components/ui/Input.tsx
Normal file
150
apps/frontend/src/components/ui/Input.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import React from 'react';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Input: React.FC<InputProps> = ({
|
||||
label,
|
||||
error,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-[color:var(--fluent-text-secondary)] mb-2">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
className={`
|
||||
w-full px-4 py-3
|
||||
acrylic
|
||||
border-2 border-[color:var(--fluent-border)]
|
||||
rounded-lg
|
||||
text-[color:var(--fluent-text)]
|
||||
placeholder-[color:var(--fluent-text-tertiary)]
|
||||
focus:outline-none
|
||||
focus:border-[color:var(--fluent-accent)]
|
||||
focus:ring-2
|
||||
focus:ring-[color:var(--fluent-accent)]/20
|
||||
hover:border-[color:var(--fluent-border-strong)]
|
||||
transition-all duration-200 ease-out
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-[color:var(--fluent-surface-secondary)]
|
||||
${error ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' : ''}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Textarea: React.FC<TextareaProps> = ({
|
||||
label,
|
||||
error,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-[color:var(--fluent-text)] mb-2">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
className={`
|
||||
w-full px-3 py-2
|
||||
acrylic
|
||||
border border-[color:var(--fluent-border)]
|
||||
rounded-md
|
||||
text-[color:var(--fluent-text)]
|
||||
placeholder-[color:var(--fluent-text-tertiary)]
|
||||
focus:outline-none
|
||||
focus:border-[color:var(--fluent-accent)]
|
||||
focus:ring-2
|
||||
focus:ring-[color:var(--fluent-accent)]/20
|
||||
hover:border-[color:var(--fluent-border-strong)]
|
||||
transition-all duration-150 ease-out
|
||||
resize-vertical
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-[color:var(--fluent-surface-secondary)]
|
||||
${error ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' : ''}
|
||||
${className}
|
||||
`}
|
||||
style={{
|
||||
boxShadow: 'var(--shadow-sm)'
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
options: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export const Select: React.FC<SelectProps> = ({
|
||||
label,
|
||||
error,
|
||||
options,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-[color:var(--fluent-text)] mb-2">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
className={`
|
||||
w-full px-3 py-2
|
||||
acrylic
|
||||
border border-[color:var(--fluent-border)]
|
||||
rounded-md
|
||||
text-[color:var(--fluent-text)]
|
||||
focus:outline-none
|
||||
focus:border-[color:var(--fluent-accent)]
|
||||
focus:ring-2
|
||||
focus:ring-[color:var(--fluent-accent)]/20
|
||||
hover:border-[color:var(--fluent-border-strong)]
|
||||
transition-all duration-150 ease-out
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-[color:var(--fluent-surface-secondary)]
|
||||
${error ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' : ''}
|
||||
${className}
|
||||
`}
|
||||
style={{
|
||||
boxShadow: 'var(--shadow-sm)'
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value} className="acrylic text-[color:var(--fluent-text)]">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
62
apps/frontend/src/contexts/ThemeContext.tsx
Normal file
62
apps/frontend/src/contexts/ThemeContext.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>('light');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const savedTheme = localStorage.getItem('theme') as Theme | null;
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
|
||||
setTheme(initialTheme);
|
||||
|
||||
if (initialTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prevTheme: Theme) => {
|
||||
const newTheme = prevTheme === 'light' ? 'dark' : 'light';
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
if (newTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
return newTheme;
|
||||
});
|
||||
};
|
||||
|
||||
// Always provide context, even before mounted
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
9
apps/frontend/src/lib/auth-client.ts
Normal file
9
apps/frontend/src/lib/auth-client.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: API_URL,
|
||||
});
|
||||
|
||||
export const { signIn, signUp, signOut, useSession } = authClient;
|
||||
64
apps/frontend/tailwind.config.ts
Normal file
64
apps/frontend/tailwind.config.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
darkMode: 'selector',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Modern Glassmorphism colors
|
||||
'fluent-bg': '#f3f3f3',
|
||||
'fluent-surface': '#ffffff',
|
||||
'fluent-accent': '#0078d4',
|
||||
'fluent-accent-dark': '#005a9e',
|
||||
'fluent-text': '#323130',
|
||||
'fluent-text-secondary': '#605e5c',
|
||||
'fluent-border': '#e1dfdd',
|
||||
},
|
||||
backdropBlur: {
|
||||
'fluent': '40px',
|
||||
'xl': '24px',
|
||||
},
|
||||
boxShadow: {
|
||||
'fluent': '0 8px 16px rgba(0, 0, 0, 0.14)',
|
||||
'fluent-hover': '0 16px 32px rgba(0, 0, 0, 0.18)',
|
||||
'2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
},
|
||||
borderRadius: {
|
||||
'fluent': '8px',
|
||||
'fluent-lg': '12px',
|
||||
'2xl': '1rem',
|
||||
'3xl': '1.5rem',
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.3s ease-in-out',
|
||||
'slide-in-from-top': 'slideInFromTop 0.3s ease-out',
|
||||
'slide-in-from-bottom': 'slideInFromBottom 0.3s ease-out',
|
||||
'pulse': 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideInFromTop: {
|
||||
'0%': { transform: 'translateY(-10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
slideInFromBottom: {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
},
|
||||
scale: {
|
||||
'102': '1.02',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
export default config;
|
||||
42
apps/frontend/tsconfig.json
Normal file
42
apps/frontend/tsconfig.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"target": "ES2017",
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
71
docker-compose.yml
Normal file
71
docker-compose.yml
Normal file
@ -0,0 +1,71 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:alpine
|
||||
container_name: sportbuddy-db
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-sportbuddy}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-sportbuddy123}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-sportbuddy}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-sportbuddy}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- sportbuddy-network
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./apps/backend
|
||||
target: development
|
||||
container_name: sportbuddy-backend
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
ports:
|
||||
- "3001:3001"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./apps/backend:/app
|
||||
- backend_node_modules:/app/node_modules
|
||||
- backend_next:/app/.next
|
||||
networks:
|
||||
- sportbuddy-network
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./apps/frontend
|
||||
target: development
|
||||
container_name: sportbuddy-frontend
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- backend
|
||||
volumes:
|
||||
- ./apps/frontend:/app
|
||||
- frontend_node_modules:/app/node_modules
|
||||
- frontend_next:/app/.next
|
||||
networks:
|
||||
- sportbuddy-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
backend_node_modules:
|
||||
backend_next:
|
||||
frontend_node_modules:
|
||||
frontend_next:
|
||||
|
||||
networks:
|
||||
sportbuddy-network:
|
||||
driver: bridge
|
||||
10280
package-lock.json
generated
Normal file
10280
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "sportbuddy-monorepo",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev -w @sportbuddy/backend\" \"npm run dev -w @sportbuddy/frontend\"",
|
||||
"build": "npm run build -w @sportbuddy/backend && npm run build -w @sportbuddy/frontend",
|
||||
"backend": "npm run dev -w @sportbuddy/backend",
|
||||
"frontend": "npm run dev -w @sportbuddy/frontend",
|
||||
"docker:up": "docker-compose up -d",
|
||||
"docker:down": "docker-compose down",
|
||||
"docker:rebuild": "docker-compose down && docker-compose build --no-cache && docker-compose up -d"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.0"
|
||||
}
|
||||
}
|
||||
10
packages/shared/package.json
Normal file
10
packages/shared/package.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@sportbuddy/shared",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"zod": "^4.1.12"
|
||||
}
|
||||
}
|
||||
2
packages/shared/src/index.ts
Normal file
2
packages/shared/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
// Shared types and schemas
|
||||
export * from './types';
|
||||
3
packages/shared/src/types/index.ts
Normal file
3
packages/shared/src/types/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
// Export shared types here
|
||||
export type SportType = 'FOOTBALL' | 'BASKETBALL' | 'TENNIS' | 'VOLLEYBALL' | 'RUNNING' | 'CYCLING';
|
||||
export type SkillLevel = 'BEGINNER' | 'INTERMEDIATE' | 'ADVANCED' | 'EXPERT';
|
||||
42
tsconfig.json
Normal file
42
tsconfig.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"target": "ES2017",
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user