first commit
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
package-lock.json
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"nuxt.isNuxtApp": false
|
||||||
|
}
|
||||||
39
README.md
Normal file
39
README.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# WisecoltCI Monorepo Starter
|
||||||
|
|
||||||
|
Minimal, üretime hazır bir full-stack başlangıç kiti. React (Vite, TypeScript, shadcn/ui, Tailwind), Express + Socket.io (TypeScript), MongoDB ve Docker Compose ile dev ortamında hot-reload destekler.
|
||||||
|
|
||||||
|
## Gereksinimler
|
||||||
|
- Docker ve Docker Compose
|
||||||
|
|
||||||
|
## Kurulum
|
||||||
|
1. Ortam dosyalarını oluşturun:
|
||||||
|
```bash
|
||||||
|
cp backend/.env.example backend/.env
|
||||||
|
cp frontend/.env.example frontend/.env
|
||||||
|
```
|
||||||
|
İstediğiniz admin bilgilerini `.env` dosyalarına girin.
|
||||||
|
|
||||||
|
2. Servisleri başlatın:
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Uygulamaya erişin:
|
||||||
|
- Frontend: http://localhost:5173
|
||||||
|
- Backend API: http://localhost:4000
|
||||||
|
- MongoDB: localhost:27017
|
||||||
|
|
||||||
|
## Giriş Bilgisi
|
||||||
|
`.env` dosyasındaki değerleri kullanın (varsayılanlar):
|
||||||
|
- Kullanıcı adı: `admin`
|
||||||
|
- Şifre: `supersecret`
|
||||||
|
|
||||||
|
## Özellikler
|
||||||
|
- **Auth**: `/auth/login` ile .env'deki kimlik bilgilerini kontrol eder, JWT döner; `/auth/me` korumalı.
|
||||||
|
- **Socket.io**: Login sonrası frontend token ile bağlanır, basit `ping/pong` olayı mevcut.
|
||||||
|
- **Tema**: shadcn/ui teması, Tailwind sınıf stratejisi, localStorage kalıcılığı.
|
||||||
|
- **Hot Reload**: Backend `tsx watch`, Frontend Vite dev server.
|
||||||
|
|
||||||
|
## Notlar
|
||||||
|
- Frontend API adresi `frontend/.env` içindeki `VITE_API_URL` ile ayarlanır.
|
||||||
|
- Docker bind mount sayesinde kod değişiklikleri konteynerde otomatik yansır.
|
||||||
4
backend/.dockerignore
Normal file
4
backend/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
dist
|
||||||
|
.env
|
||||||
6
backend/.env.example
Normal file
6
backend/.env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
PORT=4000
|
||||||
|
MONGO_URI=mongodb://mongo:27017/wisecoltci
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=supersecret
|
||||||
|
JWT_SECRET=change-me
|
||||||
|
CLIENT_ORIGIN=http://localhost:5173
|
||||||
12
backend/Dockerfile
Normal file
12
backend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json .
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY tsconfig.json .
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
EXPOSE 4000
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
28
backend/package.json
Normal file
28
backend/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"mongoose": "^7.6.3",
|
||||||
|
"socket.io": "^4.7.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
|
"@types/node": "^20.9.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsx": "^4.7.1",
|
||||||
|
"typescript": "^5.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backend/src/config/env.ts
Normal file
16
backend/src/config/env.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
port: parseInt(process.env.PORT || "4000", 10),
|
||||||
|
mongoUri: process.env.MONGO_URI || "mongodb://mongo:27017/wisecoltci",
|
||||||
|
adminUsername: process.env.ADMIN_USERNAME || "admin",
|
||||||
|
adminPassword: process.env.ADMIN_PASSWORD || "password",
|
||||||
|
jwtSecret: process.env.JWT_SECRET || "changeme",
|
||||||
|
clientOrigin: process.env.CLIENT_ORIGIN || "http://localhost:5173"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!config.jwtSecret) {
|
||||||
|
throw new Error("JWT_SECRET is required");
|
||||||
|
}
|
||||||
70
backend/src/index.ts
Normal file
70
backend/src/index.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import http from "http";
|
||||||
|
import express from "express";
|
||||||
|
import cors from "cors";
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
import { Server } from "socket.io";
|
||||||
|
import authRoutes from "./routes/auth.js";
|
||||||
|
import { config } from "./config/env.js";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: config.clientOrigin,
|
||||||
|
credentials: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.get("/health", (_req, res) => {
|
||||||
|
res.json({ status: "ok" });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use("/auth", authRoutes);
|
||||||
|
|
||||||
|
const server = http.createServer(app);
|
||||||
|
|
||||||
|
const io = new Server(server, {
|
||||||
|
cors: {
|
||||||
|
origin: config.clientOrigin,
|
||||||
|
methods: ["GET", "POST"]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
io.use((socket, next) => {
|
||||||
|
const token = socket.handshake.auth?.token as string | undefined;
|
||||||
|
if (!token) {
|
||||||
|
return next(new Error("Yetkisiz"));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
jwt.verify(token, config.jwtSecret);
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
next(new Error("Geçersiz token"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
io.on("connection", (socket) => {
|
||||||
|
socket.emit("hello", "Socket bağlantısı kuruldu");
|
||||||
|
|
||||||
|
socket.on("ping", () => {
|
||||||
|
socket.emit("pong", "pong");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
try {
|
||||||
|
await mongoose.connect(config.mongoUri);
|
||||||
|
console.log("MongoDB'ye bağlanıldı");
|
||||||
|
|
||||||
|
server.listen(config.port, () => {
|
||||||
|
console.log(`Sunucu ${config.port} portunda çalışıyor`);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Başlatma hatası", err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start();
|
||||||
23
backend/src/middleware/authMiddleware.ts
Normal file
23
backend/src/middleware/authMiddleware.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { config } from "../config/env.js";
|
||||||
|
|
||||||
|
export interface AuthRequest extends Request {
|
||||||
|
user?: { username: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authMiddleware = (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||||
|
return res.status(401).json({ message: "Yetkisiz" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.split(" ")[1];
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, config.jwtSecret) as { username: string };
|
||||||
|
req.user = { username: decoded.username };
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(401).json({ message: "Geçersiz token" });
|
||||||
|
}
|
||||||
|
};
|
||||||
30
backend/src/routes/auth.ts
Normal file
30
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { authMiddleware, AuthRequest } from "../middleware/authMiddleware.js";
|
||||||
|
import { config } from "../config/env.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post("/login", (req, res) => {
|
||||||
|
const { username, password } = req.body as { username?: string; password?: string };
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400).json({ message: "Kullanıcı adı ve şifre gerekli" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username !== config.adminUsername || password !== config.adminPassword) {
|
||||||
|
return res.status(401).json({ message: "Geçersiz kimlik bilgileri" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign({ username }, config.jwtSecret, { expiresIn: "1h" });
|
||||||
|
return res.json({ token, username });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/me", authMiddleware, (req: AuthRequest, res) => {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ message: "Yetkisiz" });
|
||||||
|
}
|
||||||
|
return res.json({ username: req.user.username });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
17
backend/tsconfig.json
Normal file
17
backend/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
39
docker-compose.yml
Normal file
39
docker-compose.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
mongo:
|
||||||
|
image: mongo:7
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "27017:27017"
|
||||||
|
volumes:
|
||||||
|
- mongo-data:/data/db
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
command: npm run dev
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- /app/node_modules
|
||||||
|
env_file:
|
||||||
|
- ./backend/.env
|
||||||
|
ports:
|
||||||
|
- "4000:4000"
|
||||||
|
depends_on:
|
||||||
|
- mongo
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
command: npm run dev -- --host --port 5173
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
- /app/node_modules
|
||||||
|
env_file:
|
||||||
|
- ./frontend/.env
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mongo-data:
|
||||||
4
frontend/.dockerignore
Normal file
4
frontend/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL=http://localhost:4000
|
||||||
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json .
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY tsconfig.json tsconfig.node.json vite.config.ts tailwind.config.js postcss.config.js index.html .
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
EXPOSE 5173
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Wisecolt Starter</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-background text-foreground">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
35
frontend/package.json
Normal file
35
frontend/package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host --port 5173",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
"axios": "^1.5.1",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"lucide-react": "^0.292.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.21.0",
|
||||||
|
"socket.io-client": "^4.7.2",
|
||||||
|
"sonner": "^1.4.0",
|
||||||
|
"tailwind-merge": "^1.14.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.9.0",
|
||||||
|
"@types/react": "^18.2.28",
|
||||||
|
"@types/react-dom": "^18.2.12",
|
||||||
|
"@vitejs/plugin-react": "^4.1.1",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"postcss": "^8.4.31",
|
||||||
|
"tailwindcss": "^3.3.3",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^4.4.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
18
frontend/src/App.tsx
Normal file
18
frontend/src/App.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
|
import { AdminPage } from "./pages/AdminPage";
|
||||||
|
import { ProtectedRoute } from "./components/ProtectedRoute";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route element={<ProtectedRoute />}>
|
||||||
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
27
frontend/src/api/client.ts
Normal file
27
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const apiBaseURL = import.meta.env.VITE_API_URL || "http://localhost:4000";
|
||||||
|
|
||||||
|
export const apiClient = axios.create({
|
||||||
|
baseURL: apiBaseURL
|
||||||
|
});
|
||||||
|
|
||||||
|
export function setAuthToken(token?: string) {
|
||||||
|
if (token) {
|
||||||
|
apiClient.defaults.headers.common["Authorization"] = `Bearer ${token}`;
|
||||||
|
localStorage.setItem("token", token);
|
||||||
|
} else {
|
||||||
|
delete apiClient.defaults.headers.common["Authorization"];
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginRequest(username: string, password: string) {
|
||||||
|
const { data } = await apiClient.post("/auth/login", { username, password });
|
||||||
|
return data as { token: string; username: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMe() {
|
||||||
|
const { data } = await apiClient.get("/auth/me");
|
||||||
|
return data as { username: string };
|
||||||
|
}
|
||||||
21
frontend/src/components/ProtectedRoute.tsx
Normal file
21
frontend/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Navigate, Outlet } from "react-router-dom";
|
||||||
|
import { useAuth } from "../providers/auth-provider";
|
||||||
|
|
||||||
|
export function ProtectedRoute() {
|
||||||
|
const { token, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Yükleniyor...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
13
frontend/src/components/ThemeToggle.tsx
Normal file
13
frontend/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { useTheme } from "../providers/theme-provider";
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="outline" size="icon" aria-label="Tema değiştir" onClick={toggleTheme}>
|
||||||
|
{theme === "light" ? <Moon className="h-5 w-5" /> : <Sun className="h-5 w-5" />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
frontend/src/components/ui/button.tsx
Normal file
51
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline"
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
51
frontend/src/components/ui/card.tsx
Normal file
51
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||||
|
));
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
21
frontend/src/components/ui/input.tsx
Normal file
21
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export { Input };
|
||||||
16
frontend/src/components/ui/label.tsx
Normal file
16
frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
HTMLLabelElement,
|
||||||
|
React.LabelHTMLAttributes<HTMLLabelElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = "Label";
|
||||||
|
|
||||||
|
export { Label };
|
||||||
24
frontend/src/components/ui/switch.tsx
Normal file
24
frontend/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-6 w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
));
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
16
frontend/src/components/ui/toaster.tsx
Normal file
16
frontend/src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Toaster as SonnerToaster } from "sonner";
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
return (
|
||||||
|
<SonnerToaster
|
||||||
|
position="top-right"
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
background: "hsl(var(--card))",
|
||||||
|
color: "hsl(var(--card-foreground))",
|
||||||
|
border: "1px solid hsl(var(--border))"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
frontend/src/index.css
Normal file
57
frontend/src/index.css
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 0 0% 4%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 0 0% 4%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 0 0% 4%;
|
||||||
|
--primary: 0 0% 4%;
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
--secondary: 0 0% 96.1%;
|
||||||
|
--secondary-foreground: 0 0% 9%;
|
||||||
|
--muted: 0 0% 96.1%;
|
||||||
|
--muted-foreground: 0 0% 45.1%;
|
||||||
|
--accent: 0 0% 96.1%;
|
||||||
|
--accent-foreground: 0 0% 9%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 0 0% 89.8%;
|
||||||
|
--input: 0 0% 89.8%;
|
||||||
|
--ring: 0 0% 4%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 0 0% 5%;
|
||||||
|
--foreground: 0 0% 95%;
|
||||||
|
--card: 0 0% 9%;
|
||||||
|
--card-foreground: 0 0% 95%;
|
||||||
|
--popover: 0 0% 9%;
|
||||||
|
--popover-foreground: 0 0% 95%;
|
||||||
|
--primary: 0 0% 95%;
|
||||||
|
--primary-foreground: 0 0% 9%;
|
||||||
|
--secondary: 0 0% 14.9%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
--muted: 0 0% 14.9%;
|
||||||
|
--muted-foreground: 0 0% 63.9%;
|
||||||
|
--accent: 0 0% 14.9%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 0 0% 14.9%;
|
||||||
|
--input: 0 0% 14.9%;
|
||||||
|
--ring: 0 0% 83.1%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-shadow {
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
21
frontend/src/main.tsx
Normal file
21
frontend/src/main.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
import { ThemeProvider } from "./providers/theme-provider";
|
||||||
|
import { AuthProvider } from "./providers/auth-provider";
|
||||||
|
import { Toaster } from "./components/ui/toaster";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ThemeProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
<Toaster />
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
113
frontend/src/pages/AdminPage.tsx
Normal file
113
frontend/src/pages/AdminPage.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { io, Socket } from "socket.io-client";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
||||||
|
import { ThemeToggle } from "../components/ThemeToggle";
|
||||||
|
import { useAuth } from "../providers/auth-provider";
|
||||||
|
import { apiClient } from "../api/client";
|
||||||
|
|
||||||
|
export function AdminPage() {
|
||||||
|
const { user, token, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [socket, setSocket] = useState<Socket | null>(null);
|
||||||
|
const [messages, setMessages] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const socketUrl = useMemo(() => apiClient.defaults.baseURL || window.location.origin, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
const newSocket = io(socketUrl, {
|
||||||
|
auth: { token },
|
||||||
|
transports: ["websocket", "polling"]
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on("connect", () => setMessages((prev) => [...prev, "Socket bağlandı"]));
|
||||||
|
newSocket.on("hello", (msg: string) => setMessages((prev) => [...prev, msg]));
|
||||||
|
newSocket.on("pong", (msg: string) => setMessages((prev) => [...prev, msg]));
|
||||||
|
newSocket.on("connect_error", (err) => {
|
||||||
|
setMessages((prev) => [...prev, `Hata: ${err.message}`]);
|
||||||
|
toast.error("Socket bağlantısı başarısız");
|
||||||
|
});
|
||||||
|
|
||||||
|
setSocket(newSocket);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
newSocket.disconnect();
|
||||||
|
};
|
||||||
|
}, [token, socketUrl]);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendPing = () => {
|
||||||
|
if (!socket) return;
|
||||||
|
socket.emit("ping");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
|
<header className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="text-lg font-semibold">Yönetim Paneli</div>
|
||||||
|
{user?.username && <span className="text-sm text-muted-foreground">Hoş geldin, {user.username}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ThemeToggle />
|
||||||
|
<Button variant="outline" onClick={handleLogout}>
|
||||||
|
Çıkış Yap
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto grid max-w-4xl gap-6 px-6 py-10 md:grid-cols-2">
|
||||||
|
<Card className="border-border card-shadow">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Bağlantı</CardTitle>
|
||||||
|
<CardDescription>Backend'e bağlanıp basit ping/pong testi yapın.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Button onClick={sendPing} disabled={!socket}>
|
||||||
|
Ping Gönder
|
||||||
|
</Button>
|
||||||
|
<div className="rounded-md border border-border bg-muted/40 p-3 text-sm text-muted-foreground">
|
||||||
|
<div className="mb-2 text-xs uppercase tracking-wide text-foreground">Socket Mesajları</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{messages.length === 0 && <div>Mesaj yok.</div>}
|
||||||
|
{messages.map((msg, idx) => (
|
||||||
|
<div key={idx} className="text-foreground/80">
|
||||||
|
• {msg}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-border card-shadow">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Durum</CardTitle>
|
||||||
|
<CardDescription>Auth & bağlantı bilgileri.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>API URL</span>
|
||||||
|
<span className="font-medium text-foreground">{socketUrl}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Kullanıcı</span>
|
||||||
|
<span className="font-medium text-foreground">{user?.username ?? "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Token</span>
|
||||||
|
<span className="truncate font-mono text-foreground/80">{token?.slice(0, 24) ?? "-"}...</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
frontend/src/pages/LoginPage.tsx
Normal file
72
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { Input } from "../components/ui/input";
|
||||||
|
import { Label } from "../components/ui/label";
|
||||||
|
import { useAuth } from "../providers/auth-provider";
|
||||||
|
import { ThemeToggle } from "../components/ThemeToggle";
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
navigate("/admin", { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Giriş başarısız. Bilgileri kontrol edin.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||||
|
<div className="absolute right-6 top-6">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
<Card className="w-full max-w-md border-border card-shadow">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Yönetici Girişi</CardTitle>
|
||||||
|
<CardDescription>Panel erişimi için bilgilerinizi girin.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">Kullanıcı adı</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="admin"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Şifre</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? "Giriş yapılıyor..." : "Giriş Yap"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
frontend/src/providers/auth-provider.tsx
Normal file
58
frontend/src/providers/auth-provider.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
import { fetchMe, loginRequest, setAuthToken } from "../api/client";
|
||||||
|
|
||||||
|
interface AuthContextProps {
|
||||||
|
user: { username: string } | null;
|
||||||
|
token: string | null;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextProps | undefined>(undefined);
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<{ username: string } | null>(null);
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem("token");
|
||||||
|
if (stored) {
|
||||||
|
setAuthToken(stored);
|
||||||
|
setToken(stored);
|
||||||
|
fetchMe()
|
||||||
|
.then((data) => setUser({ username: data.username }))
|
||||||
|
.catch(() => setAuthToken(undefined))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (username: string, password: string) => {
|
||||||
|
const { token: newToken, username: returnedUsername } = await loginRequest(username, password);
|
||||||
|
setAuthToken(newToken);
|
||||||
|
setToken(newToken);
|
||||||
|
setUser({ username: returnedUsername });
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
setAuthToken(undefined);
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ user, token, login, logout, loading }),
|
||||||
|
[user, token, loading]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error("useAuth AuthProvider içinde kullanılmalı");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
37
frontend/src/providers/theme-provider.tsx
Normal file
37
frontend/src/providers/theme-provider.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type Theme = "light" | "dark";
|
||||||
|
|
||||||
|
interface ThemeContextProps {
|
||||||
|
theme: Theme;
|
||||||
|
toggleTheme: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextProps | undefined>(undefined);
|
||||||
|
|
||||||
|
const storageKey = "theme";
|
||||||
|
|
||||||
|
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
|
const stored = localStorage.getItem(storageKey) as Theme | null;
|
||||||
|
if (stored === "light" || stored === "dark") return stored;
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = window.document.documentElement;
|
||||||
|
root.classList.remove(theme === "light" ? "dark" : "light");
|
||||||
|
root.classList.add(theme);
|
||||||
|
localStorage.setItem(storageKey, theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const toggleTheme = () => setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||||
|
|
||||||
|
return <ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTheme = () => {
|
||||||
|
const ctx = useContext(ThemeContext);
|
||||||
|
if (!ctx) throw new Error("useTheme ThemeProvider içinde kullanılmalı");
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
53
frontend/tailwind.config.js
Normal file
53
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: "class",
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{ts,tsx}"
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))"
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))"
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))"
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))"
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))"
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))"
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
};
|
||||||
8
frontend/tsconfig.json
Normal file
8
frontend/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.node.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
16
frontend/tsconfig.node.json
Normal file
16
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"lib": ["ES2020", "DOM"],
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
10
frontend/vite.config.ts
Normal file
10
frontend/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
host: true
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user