feat: mm-ui v1 + API CORS + client auth middleware

React + Vite + Tabler UI. Sayfalar: anasayfa (issue listesi), giriş,
kayıt, issue detay, sorun bildir. Axios interceptor ile token refresh.
API: CORS origin whitelist, CORSMiddleware eklendi.
This commit is contained in:
Mukan Erkin TÖRÜK 2026-04-28 01:06:01 +03:00
parent 2498e75594
commit 79518f79ac
33 changed files with 4256 additions and 0 deletions

3
.gitignore vendored
View file

@ -7,3 +7,6 @@ __pycache__/
*.sqlite *.sqlite
/uploads/ /uploads/
.DS_Store .DS_Store
mm_ui/node_modules/
mm_ui/dist/
mm_ui/.env

View file

@ -19,6 +19,18 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Memleketmeselesi API", lifespan=lifespan) app = FastAPI(title="Memleketmeselesi API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://memleketmeselesi.org.tr",
"https://www.memleketmeselesi.org.tr",
"https://memleketmeselesi.net.tr",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.middleware("http")(client_auth_middleware) app.middleware("http")(client_auth_middleware)
app.include_router(locations.router) app.include_router(locations.router)

2
mm_ui/.env.example Normal file
View file

@ -0,0 +1,2 @@
VITE_API_URL=https://api.memleketmeselesi.org.tr
VITE_API_KEY=your-api-key-here

18
mm_ui/.eslintrc.cjs Normal file
View file

@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

24
mm_ui/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

27
mm_ui/README.md Normal file
View file

@ -0,0 +1,27 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
```
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list

16
mm_ui/index.html Normal file
View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="tr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Memleketmeselesi</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3269
mm_ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

33
mm_ui/package.json Normal file
View file

@ -0,0 +1,33 @@
{
"name": "mm_ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@tabler/core": "^1.4.0",
"@tabler/icons-react": "^3.41.1",
"axios": "^1.15.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^7.14.2",
"zustand": "^5.0.12"
},
"devDependencies": {
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react": "^4.0.3",
"eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"typescript": "^5.0.2",
"vite": "^4.4.5"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
mm_ui/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

38
mm_ui/src/App.tsx Normal file
View file

@ -0,0 +1,38 @@
import { useEffect } from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import Layout from './components/Layout'
import Home from './pages/Home'
import Login from './pages/Login'
import Register from './pages/Register'
import IssueDetail from './pages/IssueDetail'
import CreateIssue from './pages/CreateIssue'
import { useAuth } from './store/auth'
import { me } from './api/auth'
export default function App() {
const { setUser } = useAuth()
useEffect(() => {
const token = localStorage.getItem('access_token')
if (token) {
me().then(setUser).catch(() => {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
})
}
}, [])
return (
<BrowserRouter>
<Layout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/giris" element={<Login />} />
<Route path="/kayit" element={<Register />} />
<Route path="/sorunlar/:id" element={<IssueDetail />} />
<Route path="/sorun-bildir" element={<CreateIssue />} />
</Routes>
</Layout>
</BrowserRouter>
)
}

13
mm_ui/src/api/auth.ts Normal file
View file

@ -0,0 +1,13 @@
import { api } from './client'
export const register = (email: string, password: string) =>
api.post('/auth/register', { email, password }).then(r => r.data)
export const login = (email: string, password: string) =>
api.post('/auth/login', { email, password }).then(r => r.data)
export const logout = () =>
api.post('/auth/logout').then(r => r.data)
export const me = () =>
api.get('/auth/me').then(r => r.data)

41
mm_ui/src/api/client.ts Normal file
View file

@ -0,0 +1,41 @@
import axios from 'axios'
const API_BASE = import.meta.env.VITE_API_URL ?? 'https://api.memleketmeselesi.org.tr'
const API_KEY = import.meta.env.VITE_API_KEY ?? ''
export const api = axios.create({
baseURL: API_BASE,
headers: { 'X-Api-Key': API_KEY },
})
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
api.interceptors.response.use(
(res) => res,
async (err) => {
const original = err.config
if (err.response?.status === 401 && !original._retry) {
original._retry = true
const refresh = localStorage.getItem('refresh_token')
if (refresh) {
try {
const { data } = await axios.post(`${API_BASE}/auth/refresh`, { refresh_token: refresh }, {
headers: { 'X-Api-Key': API_KEY },
})
localStorage.setItem('access_token', data.access_token)
localStorage.setItem('refresh_token', data.refresh_token)
original.headers.Authorization = `Bearer ${data.access_token}`
return api(original)
} catch {
localStorage.clear()
window.location.href = '/giris'
}
}
}
return Promise.reject(err)
}
)

25
mm_ui/src/api/issues.ts Normal file
View file

@ -0,0 +1,25 @@
import { api } from './client'
export const listIssues = (params?: Record<string, unknown>) =>
api.get('/issues', { params }).then(r => r.data)
export const getIssue = (id: number) =>
api.get(`/issues/${id}`).then(r => r.data)
export const createIssue = (data: { title: string; body: string; category_id: number; location_id: number }) =>
api.post('/issues', data).then(r => r.data)
export const updateIssue = (id: number, data: { title?: string; body?: string }) =>
api.patch(`/issues/${id}`, data).then(r => r.data)
export const voteIssue = (id: number, vote: 'resolved' | 'ongoing') =>
api.post(`/issues/${id}/vote`, { vote }).then(r => r.data)
export const listComments = (id: number) =>
api.get(`/issues/${id}/comments`).then(r => r.data)
export const addComment = (id: number, body: string, parent_id?: number) =>
api.post(`/issues/${id}/comments`, { body, parent_id }).then(r => r.data)
export const listCategories = (parent_id?: number) =>
api.get('/issues/categories', { params: parent_id != null ? { parent_id } : {} }).then(r => r.data)

View file

@ -0,0 +1,7 @@
import { api } from './client'
export const listLocations = (parent_id?: number) =>
api.get('/locations', { params: parent_id != null ? { parent_id } : {} }).then(r => r.data)
export const searchLocations = (q: string) =>
api.get('/locations/search', { params: { q } }).then(r => r.data)

27
mm_ui/src/assets/logo.svg Normal file
View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW X7 -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="277.896mm" height="60mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 27790 6000"
xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<style type="text/css">
<![CDATA[
.fil1 {fill:#4D4D4D}
.fil0 {fill:#990000}
.fil2 {fill:white}
]]>
</style>
</defs>
<g id="Layer_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<g id="_2303552305504">
<path class="fil0" d="M8281 5500l-693 -1690 0 1690 -623 0 0 -2547 909 0 742 1819 763 -1819 895 0 0 2547 -624 0 0 -1683 -700 1683 -669 0zm4811 -1028l-1661 0 0 478 1675 0 0 550 -2358 0 0 -2547 2351 0 0 551 -1668 0 0 477 1661 0 0 491zm1084 4c-144,0 -265,-16 -361,-47 -96,-32 -174,-79 -232,-143 -58,-64 -99,-144 -123,-240 -25,-97 -37,-210 -37,-340 0,-125 12,-235 35,-329 23,-94 63,-173 120,-235 57,-63 134,-110 230,-142 96,-31 219,-47 368,-47l1626 0 0 572 -1487 0c-79,0 -134,17 -166,52 -31,35 -47,93 -47,174 0,82 16,139 47,173 32,33 87,50 166,50l822 0c144,0 264,15 361,45 96,31 173,77 230,140 56,63 97,141 121,237 25,95 37,206 37,334 0,128 -12,240 -35,336 -23,97 -64,177 -122,241 -58,64 -134,112 -230,144 -95,33 -216,49 -362,49l-1689 0 0 -568 1553 0c77,0 131,-18 164,-54 32,-36 49,-96 49,-179 0,-88 -17,-148 -51,-178 -33,-30 -87,-45 -162,-45l-825 0zm4427 -4l-1661 0 0 478 1675 0 0 550 -2358 0 0 -2547 2351 0 0 551 -1668 0 0 477 1661 0 0 491zm1122 478l1575 0 0 550 -2258 0 0 -2547 683 0 0 1997zm4215 -478l-1662 0 0 478 1676 0 0 550 -2358 0 0 -2547 2351 0 0 551 -1669 0 0 477 1662 0 0 491zm1084 4c-144,0 -265,-16 -361,-47 -96,-32 -174,-79 -232,-143 -58,-64 -99,-144 -123,-240 -25,-97 -37,-210 -37,-340 0,-125 12,-235 35,-329 23,-94 63,-173 120,-235 57,-63 134,-110 230,-142 96,-31 219,-47 368,-47l1626 0 0 572 -1487 0c-79,0 -134,17 -166,52 -31,35 -47,93 -47,174 0,82 16,139 47,173 32,33 87,50 166,50l822 0c144,0 264,15 361,45 96,31 173,77 230,140 56,63 97,141 121,237 25,95 37,206 37,334 0,128 -12,240 -35,336 -23,97 -64,177 -122,241 -58,64 -134,112 -230,144 -95,33 -216,49 -362,49l-1689 0 0 -568 1553 0c77,0 131,-18 164,-54 32,-36 49,-96 49,-179 0,-88 -17,-148 -51,-178 -33,-30 -87,-45 -162,-45l-825 0zm2766 -1523l0 2547 -683 0 0 -2547 683 0zm-627 -724l574 0 0 512 -574 0 0 -512z"/>
<path class="fil1" d="M8123 2741l-610 -1487 0 1487 -549 0 0 -2241 801 0 653 1600 671 -1600 788 0 0 2241 -549 0 0 -1481 -616 1481 -589 0zm4234 -904l-1462 0 0 420 1474 0 0 484 -2075 0 0 -2241 2069 0 0 484 -1468 0 0 420 1462 0 0 433zm1545 904l-610 -1487 0 1487 -549 0 0 -2241 800 0 653 1600 672 -1600 788 0 0 2241 -549 0 0 -1481 -616 1481 -589 0zm2771 -484l1386 0 0 484 -1986 0 0 -2241 600 0 0 1757zm3710 -420l-1462 0 0 420 1474 0 0 484 -2075 0 0 -2241 2069 0 0 484 -1468 0 0 420 1462 0 0 433zm1895 -258l778 1162 -668 0 -632 -914 -386 0 0 914 -601 0 0 -2241 601 0 0 907 386 0 641 -907 635 0 -754 1079zm3093 258l-1463 0 0 420 1475 0 0 484 -2075 0 0 -2241 2069 0 0 484 -1469 0 0 420 1463 0 0 433zm1024 -853l-794 0 0 -484 2189 0 0 484 -794 0 0 1757 -601 0 0 -1757z"/>
<path class="fil0" d="M1925 0c527,0 1005,212 1352,555 348,-343 826,-555 1353,-555 1063,0 1925,862 1925,1925 0,532 -216,1013 -564,1361l0 0 -2714 2714 -2713 -2714 0 0c-348,-348 -564,-829 -564,-1361 0,-1063 862,-1925 1925,-1925z"/>
<path class="fil2" d="M5947 3253c-471,471 -1130,638 -1735,502 410,22 827,-123 1140,-436 584,-584 584,-1532 0,-2116 -585,-584 -1532,-584 -2116,0 -313,313 -458,730 -436,1139 -136,-604 31,-1263 502,-1734 730,-731 1914,-731 2645,0 730,730 730,1915 0,2645z"/>
<path class="fil2" d="M608 3253c471,471 1130,638 1734,502 -409,22 -826,-123 -1139,-436 -584,-584 -584,-1532 0,-2116 584,-584 1532,-584 2116,0 313,313 458,730 436,1139 136,-604 -31,-1263 -502,-1734 -730,-731 -1915,-731 -2645,0 -731,730 -731,1915 0,2645z"/>
<polygon id="polygon2209" class="fil2" points="3827,3895 3617,3248 4167,2849 3487,2849 3277,2203 3067,2849 2388,2849 2938,3248 2728,3895 3277,3495 "/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View file

@ -0,0 +1,74 @@
import { Link, useNavigate } from 'react-router-dom'
import { useAuth } from '../store/auth'
import { logout } from '../api/auth'
import logoSvg from '../assets/logo.svg'
export default function Layout({ children }: { children: React.ReactNode }) {
const { user, setUser } = useAuth()
const navigate = useNavigate()
const handleLogout = async () => {
try { await logout() } catch { /* ignore */ }
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
setUser(null)
navigate('/')
}
return (
<div className="wrapper">
<header className="navbar navbar-expand-md navbar-light d-print-none">
<div className="container-xl">
<Link to="/" className="navbar-brand">
<img src={logoSvg} alt="Memleketmeselesi" height="32" />
</Link>
<div className="navbar-nav ms-auto">
{user ? (
<>
<Link to="/sorun-bildir" className="nav-link btn btn-primary me-2">
<svg xmlns="http://www.w3.org/2000/svg" className="icon" width="24" height="24" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Sorun Bildir
</Link>
<div className="nav-item dropdown">
<a href="#" className="nav-link dropdown-toggle" data-bs-toggle="dropdown">
{user.email}
{user.kyc_status === 'verified' && (
<span className="badge bg-success ms-1">Doğrulandı</span>
)}
{user.kyc_status === 'pending' && (
<span className="badge bg-warning ms-1">Beklemede</span>
)}
{user.kyc_status === 'none' && (
<span className="badge bg-danger ms-1">Doğrulanmadı</span>
)}
</a>
<div className="dropdown-menu dropdown-menu-end">
<Link to="/profil" className="dropdown-item">Profil</Link>
{user.kyc_status === 'none' && (
<Link to="/kimlik-dogrulama" className="dropdown-item">Kimlik Doğrula</Link>
)}
<div className="dropdown-divider"/>
<button className="dropdown-item text-danger" onClick={handleLogout}>Çıkış</button>
</div>
</div>
</>
) : (
<>
<Link to="/giris" className="nav-link">Giriş</Link>
<Link to="/kayit" className="btn btn-primary ms-2">Kayıt Ol</Link>
</>
)}
</div>
</div>
</header>
<div className="page-wrapper">
<div className="container-xl py-4">
{children}
</div>
</div>
</div>
)
}

10
mm_ui/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import '@tabler/core/dist/css/tabler.min.css'
import App from './App.tsx'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View file

@ -0,0 +1,169 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { createIssue, listCategories } from '../api/issues'
import { listLocations } from '../api/locations'
import { useAuth } from '../store/auth'
export default function CreateIssue() {
const navigate = useNavigate()
const { user, isVerified } = useAuth()
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [categoryId, setCategoryId] = useState<number | ''>('')
const [parentCategoryId, setParentCategoryId] = useState<number | ''>('')
const [locationId, setLocationId] = useState<number | ''>('')
const [il, setIl] = useState<number | ''>('')
const [ilce, setIlce] = useState<number | ''>('')
const [categories, setCategories] = useState<any[]>([])
const [subCategories, setSubCategories] = useState<any[]>([])
const [iller, setIller] = useState<any[]>([])
const [ilceler, setIlceler] = useState<any[]>([])
const [mahalleler, setMahalleler] = useState<any[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!user) { navigate('/giris'); return }
if (!isVerified()) { navigate('/kimlik-dogrulama'); return }
listCategories().then(setCategories)
listLocations().then(setIller) // il seviyesi
}, [])
useEffect(() => {
if (parentCategoryId !== '') {
listCategories(parentCategoryId).then(data => {
setSubCategories(data)
setCategoryId('')
if (data.length === 0) setCategoryId(parentCategoryId)
})
} else {
setSubCategories([])
}
}, [parentCategoryId])
useEffect(() => {
if (il !== '') {
listLocations(il).then(setIlceler)
setIlce(''); setLocationId(''); setMahalleler([])
}
}, [il])
useEffect(() => {
if (ilce !== '') {
listLocations(ilce).then(data => {
setMahalleler(data)
setLocationId(ilce)
})
}
}, [ilce])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!categoryId || !locationId) { setError('Kategori ve konum seçimi zorunludur'); return }
setError(''); setLoading(true)
try {
const issue = await createIssue({ title, body, category_id: Number(categoryId), location_id: Number(locationId) })
navigate(`/sorunlar/${issue.id}`)
} catch (err: any) {
setError(err.response?.data?.detail ?? 'Sorun bildirilemedi')
} finally {
setLoading(false)
}
}
return (
<div className="row justify-content-center">
<div className="col-md-8">
<div className="page-header mb-4">
<h2 className="page-title">Sorun Bildir</h2>
</div>
<div className="card">
<div className="card-body">
{error && <div className="alert alert-danger">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label className="form-label">Başlık</label>
<input type="text" className="form-control" value={title}
onChange={e => setTitle(e.target.value)} required minLength={5} maxLength={300} />
</div>
<div className="mb-3">
<label className="form-label">ıklama</label>
<textarea className="form-control" rows={5} value={body}
onChange={e => setBody(e.target.value)} required minLength={20} />
</div>
<div className="row mb-3">
<div className="col">
<label className="form-label">Ana Kategori</label>
<select className="form-select" value={parentCategoryId}
onChange={e => setParentCategoryId(e.target.value ? Number(e.target.value) : '')}>
<option value="">Seçin...</option>
{categories.filter(c => !c.parent_id).map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
{subCategories.length > 0 && (
<div className="col">
<label className="form-label">Alt Kategori</label>
<select className="form-select" value={categoryId}
onChange={e => setCategoryId(e.target.value ? Number(e.target.value) : '')}>
<option value="">Seçin...</option>
{subCategories.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
)}
</div>
<div className="row mb-3">
<div className="col">
<label className="form-label">İl</label>
<select className="form-select" value={il}
onChange={e => setIl(e.target.value ? Number(e.target.value) : '')}>
<option value="">Seçin...</option>
{iller.map((l: any) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</div>
{ilceler.length > 0 && (
<div className="col">
<label className="form-label">İlçe</label>
<select className="form-select" value={ilce}
onChange={e => setIlce(e.target.value ? Number(e.target.value) : '')}>
<option value="">Seçin...</option>
{ilceler.map((l: any) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</div>
)}
{mahalleler.length > 0 && (
<div className="col">
<label className="form-label">Mahalle (opsiyonel)</label>
<select className="form-select" value={locationId}
onChange={e => setLocationId(e.target.value ? Number(e.target.value) : ilce)}>
<option value={ilce}>İlçe geneli</option>
{mahalleler.map((l: any) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</div>
)}
</div>
<div className="form-footer">
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? <span className="spinner-border spinner-border-sm me-2" /> : null}
Sorun Bildir
</button>
</div>
</form>
</div>
</div>
</div>
</div>
)
}

103
mm_ui/src/pages/Home.tsx Normal file
View file

@ -0,0 +1,103 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { listIssues } from '../api/issues'
interface Issue {
id: number
title: string
status: string
category_name: string
location_name: string
vote_count: number
created_at: string
}
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
open: { label: 'Açık', color: 'danger' },
in_progress: { label: 'İşlemde', color: 'warning' },
resolved: { label: 'Çözüldü', color: 'success' },
rejected: { label: 'Reddedildi', color: 'secondary' },
duplicate: { label: 'Yinelenen', color: 'secondary' },
}
export default function Home() {
const [issues, setIssues] = useState<Issue[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
listIssues({ limit: 20 })
.then(setIssues)
.finally(() => setLoading(false))
}, [])
return (
<>
<div className="page-header mb-4">
<div className="row align-items-center">
<div className="col">
<h2 className="page-title">Son Sorunlar</h2>
</div>
<div className="col-auto">
<Link to="/sorun-bildir" className="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" className="icon" width="24" height="24" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Sorun Bildir
</Link>
</div>
</div>
</div>
{loading ? (
<div className="text-center py-5">
<div className="spinner-border text-primary" />
</div>
) : issues.length === 0 ? (
<div className="empty">
<p className="empty-title">Henüz sorun bildirimi yok</p>
<p className="empty-subtitle text-muted">İlk sorun bildirimini sen yap!</p>
<div className="empty-action">
<Link to="/sorun-bildir" className="btn btn-primary">Sorun Bildir</Link>
</div>
</div>
) : (
<div className="card">
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>Başlık</th>
<th>Kategori</th>
<th>Konum</th>
<th>Durum</th>
<th>Oy</th>
<th>Tarih</th>
</tr>
</thead>
<tbody>
{issues.map(issue => {
const s = STATUS_LABELS[issue.status] ?? { label: issue.status, color: 'secondary' }
return (
<tr key={issue.id}>
<td>
<Link to={`/sorunlar/${issue.id}`} className="text-reset">
{issue.title}
</Link>
</td>
<td className="text-muted">{issue.category_name}</td>
<td className="text-muted">{issue.location_name}</td>
<td><span className={`badge bg-${s.color}-lt`}>{s.label}</span></td>
<td>{issue.vote_count}</td>
<td className="text-muted">{new Date(issue.created_at).toLocaleDateString('tr-TR')}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
</>
)
}

View file

@ -0,0 +1,143 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { getIssue, listComments, addComment, voteIssue } from '../api/issues'
import { useAuth } from '../store/auth'
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
open: { label: 'Açık', color: 'danger' },
in_progress: { label: 'İşlemde', color: 'warning' },
resolved: { label: 'Çözüldü', color: 'success' },
rejected: { label: 'Reddedildi', color: 'secondary' },
duplicate: { label: 'Yinelenen', color: 'secondary' },
}
export default function IssueDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { user, isVerified } = useAuth()
const [issue, setIssue] = useState<any>(null)
const [comments, setComments] = useState<any[]>([])
const [commentBody, setBody] = useState('')
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!user) { navigate('/giris'); return }
Promise.all([getIssue(Number(id)), listComments(Number(id))])
.then(([iss, cmts]) => { setIssue(iss); setComments(cmts) })
.finally(() => setLoading(false))
}, [id])
const handleVote = async (vote: 'resolved' | 'ongoing') => {
if (!isVerified()) return
await voteIssue(Number(id), vote)
const iss = await getIssue(Number(id))
setIssue(iss)
}
const handleComment = async (e: React.FormEvent) => {
e.preventDefault()
if (!commentBody.trim()) return
setSubmitting(true)
setError('')
try {
await addComment(Number(id), commentBody)
setBody('')
setComments(await listComments(Number(id)))
} catch (err: any) {
setError(err.response?.data?.detail ?? 'Yorum gönderilemedi')
} finally {
setSubmitting(false)
}
}
if (loading) return <div className="text-center py-5"><div className="spinner-border text-primary" /></div>
if (!issue) return <div className="alert alert-danger">Sorun bulunamadı</div>
const s = STATUS_LABELS[issue.status] ?? { label: issue.status, color: 'secondary' }
return (
<>
<div className="page-header mb-4">
<div className="row align-items-center">
<div className="col">
<h2 className="page-title">{issue.title}</h2>
<div className="text-muted mt-1">
{issue.category_name} · {issue.location_name} ·{' '}
{new Date(issue.created_at).toLocaleDateString('tr-TR')}
</div>
</div>
<div className="col-auto">
<span className={`badge bg-${s.color}-lt fs-4`}>{s.label}</span>
</div>
</div>
</div>
<div className="row">
<div className="col-md-8">
<div className="card mb-4">
<div className="card-body">
<p style={{ whiteSpace: 'pre-wrap' }}>{issue.body}</p>
</div>
</div>
<h4 className="mb-3">Yorumlar ({issue.comment_count})</h4>
{comments.map((c: any) => (
<div key={c.id} className="card mb-2">
<div className="card-body py-2">
<div className="d-flex justify-content-between">
<small className="text-muted">{new Date(c.created_at).toLocaleString('tr-TR')}</small>
</div>
<p className="mb-0 mt-1">{c.body}</p>
</div>
</div>
))}
{isVerified() ? (
<div className="card mt-3">
<div className="card-body">
<h5 className="card-title">Yorum Yap</h5>
{error && <div className="alert alert-danger">{error}</div>}
<form onSubmit={handleComment}>
<textarea className="form-control mb-2" rows={3} value={commentBody}
onChange={e => setBody(e.target.value)} placeholder="Yorumunuzu yazın..." />
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? <span className="spinner-border spinner-border-sm me-2" /> : null}
Gönder
</button>
</form>
</div>
</div>
) : (
<div className="alert alert-info mt-3">
Yorum yapabilmek için <a href="/kimlik-dogrulama">kimlik doğrulaması</a> gerekiyor.
</div>
)}
</div>
<div className="col-md-4">
<div className="card mb-3">
<div className="card-body">
<h5 className="card-title">Oy Kullan</h5>
<p className="text-muted small">Bu sorun çözüldü ?</p>
<div className="d-grid gap-2">
<button className="btn btn-success" onClick={() => handleVote('resolved')}
disabled={!isVerified()}>
Çözüldü ({issue.votes.resolved})
</button>
<button className="btn btn-outline-danger" onClick={() => handleVote('ongoing')}
disabled={!isVerified()}>
Hâlâ Devam Ediyor ({issue.votes.ongoing})
</button>
</div>
{!isVerified() && (
<small className="text-muted d-block mt-2">Oy kullanmak için kimlik doğrulaması gerekli</small>
)}
</div>
</div>
</div>
</div>
</>
)
}

65
mm_ui/src/pages/Login.tsx Normal file
View file

@ -0,0 +1,65 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { login, me } from '../api/auth'
import { useAuth } from '../store/auth'
export default function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { setUser } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const data = await login(email, password)
localStorage.setItem('access_token', data.access_token)
localStorage.setItem('refresh_token', data.refresh_token)
const user = await me()
setUser(user)
navigate('/')
} catch (err: any) {
setError(err.response?.data?.detail ?? 'Giriş başarısız')
} finally {
setLoading(false)
}
}
return (
<div className="row justify-content-center">
<div className="col-md-5">
<div className="card card-md">
<div className="card-body">
<h2 className="card-title text-center mb-4">Giriş Yap</h2>
{error && <div className="alert alert-danger">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label className="form-label">E-posta</label>
<input type="email" className="form-control" value={email}
onChange={e => setEmail(e.target.value)} required autoFocus />
</div>
<div className="mb-3">
<label className="form-label">Şifre</label>
<input type="password" className="form-control" value={password}
onChange={e => setPassword(e.target.value)} required />
</div>
<div className="form-footer">
<button type="submit" className="btn btn-primary w-100" disabled={loading}>
{loading ? <span className="spinner-border spinner-border-sm me-2" /> : null}
Giriş Yap
</button>
</div>
</form>
</div>
</div>
<div className="text-center text-muted mt-3">
Hesabın yok mu? <Link to="/kayit">Kayıt Ol</Link>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,73 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { register, login, me } from '../api/auth'
import { useAuth } from '../store/auth'
export default function Register() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { setUser } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (password !== confirm) { setError('Şifreler eşleşmiyor'); return }
setLoading(true)
try {
await register(email, password)
const data = await login(email, password)
localStorage.setItem('access_token', data.access_token)
localStorage.setItem('refresh_token', data.refresh_token)
const user = await me()
setUser(user)
navigate('/kimlik-dogrulama')
} catch (err: any) {
setError(err.response?.data?.detail ?? 'Kayıt başarısız')
} finally {
setLoading(false)
}
}
return (
<div className="row justify-content-center">
<div className="col-md-5">
<div className="card card-md">
<div className="card-body">
<h2 className="card-title text-center mb-4">Kayıt Ol</h2>
{error && <div className="alert alert-danger">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label className="form-label">E-posta</label>
<input type="email" className="form-control" value={email}
onChange={e => setEmail(e.target.value)} required autoFocus />
</div>
<div className="mb-3">
<label className="form-label">Şifre</label>
<input type="password" className="form-control" value={password}
onChange={e => setPassword(e.target.value)} required minLength={8} />
</div>
<div className="mb-3">
<label className="form-label">Şifre Tekrar</label>
<input type="password" className="form-control" value={confirm}
onChange={e => setConfirm(e.target.value)} required />
</div>
<div className="form-footer">
<button type="submit" className="btn btn-primary w-100" disabled={loading}>
{loading ? <span className="spinner-border spinner-border-sm me-2" /> : null}
Kayıt Ol
</button>
</div>
</form>
</div>
</div>
<div className="text-center text-muted mt-3">
Hesabın var mı? <Link to="/giris">Giriş Yap</Link>
</div>
</div>
</div>
)
}

21
mm_ui/src/store/auth.ts Normal file
View file

@ -0,0 +1,21 @@
import { create } from 'zustand'
interface User {
id: number
email: string
kyc_status: 'none' | 'pending' | 'verified' | 'rejected'
}
interface AuthStore {
user: User | null
setUser: (user: User | null) => void
isLoggedIn: () => boolean
isVerified: () => boolean
}
export const useAuth = create<AuthStore>((set, get) => ({
user: null,
setUser: (user) => set({ user }),
isLoggedIn: () => get().user !== null,
isVerified: () => get().user?.kyc_status === 'verified',
}))

1
mm_ui/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

25
mm_ui/tsconfig.json Normal file
View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
mm_ui/tsconfig.node.json Normal file
View file

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

7
mm_ui/vite.config.ts Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})