Sun Oct 26 15:44:27 CET 2025

This commit is contained in:
XomByik 2025-10-26 15:44:27 +01:00
commit 4b746447e0
56 changed files with 27752 additions and 0 deletions

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
.next
node_modules
dist
*.log
.env
.env.local
.DS_Store

64
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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"]

View 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).*)'],
};

View 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

File diff suppressed because it is too large Load Diff

34
apps/backend/package.json Normal file
View 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"
}
}

View 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])
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View File

@ -0,0 +1,4 @@
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);

View 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 }
);
}
}

View 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;

View 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;

View 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;
}
}

View 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;

View 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"
]
}

View 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
View 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"]

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View 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

View 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" }]
}
]
}

View 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);
}
})
);
})
);
});

View 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>
);
}

View 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)]">
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>
);
}

View 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>
);
}

View 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)
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
</>
);
}

View 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>
);
}

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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;
}

View 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;

View 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;

View 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
View 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

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View 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"
}
}

View 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"
}
}

View File

@ -0,0 +1,2 @@
// Shared types and schemas
export * from './types';

View 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
View 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"
]
}