diff --git a/CHANGELOG.md b/CHANGELOG.md index bb9dbb3..e0fee12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır ## [Yayınlanmamış] +## [0.2.0] - 2026-11-05 + +Faz 2: Frontend iskeleti (veriler Faz 3'e kadar mock). + +### Eklendi + +- Marka paleti: mavi/beyaz oklch tema (açık), lacivert/açık mavi (koyu); anlamsal `--income`, `--expense`, `--budget`, `--warning` değişkenleri ve grafik renkleri. +- Açık / Koyu / Sistem tema (next-themes) ve her ekranda tema seçici. +- Giriş ekranı (`/login`): kullanıcı adı veya e-posta + şifre, zod doğrulama, şifre göster/gizle, yükleniyor durumu; kayıt bağlantısı yok. `/` her zaman `/login`'e yönlenir. +- Uygulama kabuğu: masaüstünde daraltılabilir yan menü, mobilde alt gezinme çubuğu (ortada Hızlı ekle, "Daha fazla" ile menü), üst bar (hane seçici, Hızlı ekle, tema, kullanıcı menüsü: ad, rol, sürüm, Çıkış yap). Henüz gelmeyen sayfalar "Yakında" olarak pasif. +- Hızlı ekle (gider / gelir / transfer) seçimi; mobilde alttan açılan Drawer, masaüstünde Dialog (`ResponsiveDialog`). +- Dashboard: karşılama ve özet kartları (Suspense + Skeleton). +- Admin kullanıcı yönetimi (`/admin/kullanicilar`): TanStack Table v9 ile Türkçe duyarlı arama, sıralama, sayfalama; oluşturma, düzenleme, rol değiştirme, şifre sıfırlama, askıya alma/kaldırma, silme (onaylı). Kendini silememe ve son admini düşürememe kuralları (`checkAdminAction`). +- Paylaşılan zod şemaları (`src/shared/schemas`: giriş, kullanıcı, şifre, askı), Türkçe arama/sıralama (`foldTr`, `compareTr`), TL ve İstanbul saatli tarih biçimleri (`formatMoney`, `formatDate`). +- PWA temeli: `manifest.webmanifest`, tema rengi, Apple web app meta, ikonlar. +- `npm run icons`: `public/brand/icon.png`'den favicon.ico, uygulama, Apple ve maskable PWA ikonları üretir (sharp). +- Yer tutucu marka dosyaları (`public/brand/logo.svg`, `logo-dark.svg`, `icon.png`). +- 404, hata (`retry`) ve global hata sayfaları; yükleniyor ekranları. +- `ActionResult` sonuç tipi (Faz 3 Server Action sözleşmesi). + +### Değişti + +- `use-mobile` hook'u `useSyncExternalStore` ile yeniden yazıldı (effect içinde setState yok, hydration güvenli). +- ESLint: `_` önekli bilinçli kullanılmayan değişkenlere izin. + ## [0.1.0] - 2026-11-05 Faz 1: Framework, klasör yapısı ve altyapı. @@ -25,5 +50,6 @@ Faz 1: Framework, klasör yapısı ve altyapı. - `.nvmrc` (Node 26), `engines.node`, `typecheck`, `test`, `format`, `db:up`, `seed` scriptleri. - `docs/PROMPT.md` (ana prompt ve Faz 0 kararları), `README.md`, `CHANGELOG.md`. -[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.1.0...HEAD +[Yayınlanmamış]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.2.0...HEAD +[0.2.0]: https://gitea.ta1sph.info/suphicakir/butcem/compare/v0.1.0...v0.2.0 [0.1.0]: https://gitea.ta1sph.info/suphicakir/butcem/releases/tag/v0.1.0 diff --git a/README.md b/README.md index aa3bbda..af77d27 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,10 @@ npm run dev # http://localhost:3000 | `npm run db:up` / `db:down` | Lokal MongoDB'yi başlat / durdur | | `npm run seed` / `seed:reset` | Örnek veri (Faz 4) | +## Marka dosyaları + +`public/brand/` altındaki `logo.svg`, `logo-dark.svg` ve `icon.png` (512×512) şu an **yer tutucudur**. Gerçek logoları aynı adlarla koyup `npm run icons` çalıştırın. + ## Docker imajı ```bash diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..a301bdb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,6 +5,15 @@ import nextTs from "eslint-config-next/typescript"; const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, + { + rules: { + // "_" ile başlayan adlar bilinçli olarak kullanılmayan değişkenlerdir. + "@typescript-eslint/no-unused-vars": [ + "warn", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_", destructuredArrayIgnorePattern: "^_" }, + ], + }, + }, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: diff --git a/package-lock.json b/package-lock.json index 903eb71..f62a378 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,8 +24,10 @@ "react-hook-form": "^7.91.0", "server-only": "^0.0.1", "shadcn": "^4.23.0", + "sharp": "^0.35.5", "sonner": "^2.0.8", "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2", "zod": "^4.6.7" }, "devDependencies": { @@ -42,6 +44,9 @@ "tsx": "^4.23.15", "typescript": "^5", "vitest": "^5.1.0" + }, + "engines": { + "node": ">=26 <27" } }, "node_modules/@alloc/quick-lru": { @@ -1650,7 +1655,6 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -6776,7 +6780,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -11199,7 +11202,6 @@ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", @@ -11249,7 +11251,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz", "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==", "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, @@ -12340,6 +12341,19 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/vite": { "version": "8.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", diff --git a/package.json b/package.json index a6a3fc2..a2686e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "butcem", - "version": "0.1.0", + "version": "0.2.0", "private": true, "description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması", "engines": { @@ -19,7 +19,8 @@ "db:up": "docker compose up -d --wait", "db:down": "docker compose down", "seed": "tsx --env-file=.env.local scripts/seed.ts", - "seed:reset": "tsx --env-file=.env.local scripts/seed.ts --reset" + "seed:reset": "tsx --env-file=.env.local scripts/seed.ts --reset", + "icons": "tsx scripts/generate-icons.ts" }, "dependencies": { "@hookform/resolvers": "^7.0.2", @@ -38,8 +39,10 @@ "react-hook-form": "^7.91.0", "server-only": "^0.0.1", "shadcn": "^4.23.0", + "sharp": "^0.35.5", "sonner": "^2.0.8", "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2", "zod": "^4.6.7" }, "devDependencies": { diff --git a/public/brand/.gitkeep b/public/brand/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/public/brand/icon.png b/public/brand/icon.png new file mode 100644 index 0000000..b83858d Binary files /dev/null and b/public/brand/icon.png differ diff --git a/public/brand/logo-dark.svg b/public/brand/logo-dark.svg new file mode 100644 index 0000000..700c969 --- /dev/null +++ b/public/brand/logo-dark.svg @@ -0,0 +1,5 @@ + + + + BÜTÇEM + diff --git a/public/brand/logo.svg b/public/brand/logo.svg new file mode 100644 index 0000000..60fd7b4 --- /dev/null +++ b/public/brand/logo.svg @@ -0,0 +1,5 @@ + + + + BÜTÇEM + diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..0147bc1 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..faf8265 Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/icons/maskable-512.png b/public/icons/maskable-512.png new file mode 100644 index 0000000..609e48e Binary files /dev/null and b/public/icons/maskable-512.png differ diff --git a/scripts/generate-icons.ts b/scripts/generate-icons.ts new file mode 100644 index 0000000..c55f90d --- /dev/null +++ b/scripts/generate-icons.ts @@ -0,0 +1,90 @@ +/** + * Uygulama ikonlarını logodan üretir: npm run icons + * + * Kaynak: public/brand/icon.png (kare, en az 512×512). + * Çıktılar (commit edilir): + * src/app/favicon.ico 16/32/48 px (PNG gömülü ICO) + * src/app/icon.png 192 px → + * src/app/apple-icon.png 180 px, saydamsız → iOS ana ekran + * public/icons/icon-192.png PWA manifest + * public/icons/icon-512.png PWA manifest + * public/icons/maskable-512.png PWA manifest (purpose: maskable, %80 güvenli alan) + * + * Logoyu değiştirdikten sonra bu betiği yeniden çalıştırın. + */ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import sharp from "sharp"; + +const ROOT = process.cwd(); +const SOURCE = path.join(ROOT, "public/brand/icon.png"); +/** Saydamlık kabul etmeyen ikonlarda arka plan: marka mavisi (--primary, açık tema). */ +const BRAND_BG = "#105bbe"; + +async function png(size: number, opts: { flatten?: boolean; padding?: number } = {}) { + const inner = Math.round(size * (1 - (opts.padding ?? 0) * 2)); + const resized = await sharp(SOURCE).resize(inner, inner, { fit: "contain" }).png().toBuffer(); + let img = sharp({ + create: { + width: size, + height: size, + channels: 4, + background: opts.flatten || opts.padding ? BRAND_BG : { r: 0, g: 0, b: 0, alpha: 0 }, + }, + }).composite([{ input: resized, gravity: "center" }]); + if (opts.flatten) img = img.flatten({ background: BRAND_BG }); + return img.png({ compressionLevel: 9 }).toBuffer(); +} + +/** PNG görüntülerinden ICO dosyası oluşturur (Vista+ tüm tarayıcılar PNG gömülü ICO destekler). */ +function toIco(images: { size: number; data: Buffer }[]): Buffer { + const header = Buffer.alloc(6); + header.writeUInt16LE(0, 0); // reserved + header.writeUInt16LE(1, 2); // type: icon + header.writeUInt16LE(images.length, 4); + const entries: Buffer[] = []; + let offset = 6 + images.length * 16; + for (const { size, data } of images) { + const e = Buffer.alloc(16); + e.writeUInt8(size >= 256 ? 0 : size, 0); + e.writeUInt8(size >= 256 ? 0 : size, 1); + e.writeUInt8(0, 2); // palette + e.writeUInt8(0, 3); // reserved + e.writeUInt16LE(1, 4); // color planes + e.writeUInt16LE(32, 6); // bpp + e.writeUInt32LE(data.length, 8); + e.writeUInt32LE(offset, 12); + offset += data.length; + entries.push(e); + } + return Buffer.concat([header, ...entries, ...images.map((i) => i.data)]); +} + +async function main() { + const meta = await sharp(SOURCE).metadata(); + if (!meta.width || !meta.height || meta.width !== meta.height || meta.width < 512) { + throw new Error(`${SOURCE} kare ve en az 512×512 olmalı (şu an ${meta.width}×${meta.height}).`); + } + await mkdir(path.join(ROOT, "public/icons"), { recursive: true }); + + const out: [string, Buffer][] = [ + ["src/app/icon.png", await png(192)], + ["src/app/apple-icon.png", await png(180, { flatten: true })], + ["public/icons/icon-192.png", await png(192)], + ["public/icons/icon-512.png", await png(512)], + ["public/icons/maskable-512.png", await png(512, { padding: 0.1 })], + [ + "src/app/favicon.ico", + toIco(await Promise.all([16, 32, 48].map(async (size) => ({ size, data: await png(size) })))), + ], + ]; + for (const [file, data] of out) { + await writeFile(path.join(ROOT, file), data); + console.log(`✓ ${file} (${data.length} bayt)`); + } +} + +main().catch((err: unknown) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/src/app/(app)/admin/kullanicilar/page.tsx b/src/app/(app)/admin/kullanicilar/page.tsx new file mode 100644 index 0000000..6875a05 --- /dev/null +++ b/src/app/(app)/admin/kullanicilar/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import { PageHeader } from "@/components/page-header"; +import { UsersTable } from "@/features/admin-users/components/users-table"; +import { MOCK_ADMIN_USERS } from "@/features/admin-users/mock"; +import { MOCK_SESSION_USER } from "@/features/auth/mock"; +import { tr } from "@/i18n/tr"; + +export const metadata: Metadata = { title: tr.adminUsers.title }; + +export default function AdminUsersPage() { + // FAZ 3: const users = await listUsers() (Better Auth admin API) + return ( + <> + + + + ); +} diff --git a/src/app/(app)/admin/layout.tsx b/src/app/(app)/admin/layout.tsx new file mode 100644 index 0000000..7cc8092 --- /dev/null +++ b/src/app/(app)/admin/layout.tsx @@ -0,0 +1,11 @@ +import { notFound } from "next/navigation"; +import { MOCK_SESSION_USER } from "@/features/auth/mock"; + +/** + * Yalnızca sistem admini. Admin olmayana 404 (sayfanın varlığını bile göstermez). + * FAZ 3: `await requireRole("admin")` (lib/dal.ts) — sunucu tarafı gerçek kontrol. + */ +export default function AdminLayout({ children }: { children: React.ReactNode }) { + if (MOCK_SESSION_USER.role !== "admin") notFound(); + return children; +} diff --git a/src/app/(app)/dashboard/loading.tsx b/src/app/(app)/dashboard/loading.tsx new file mode 100644 index 0000000..d1e7bb2 --- /dev/null +++ b/src/app/(app)/dashboard/loading.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton"; + +export default function DashboardLoading() { + return ( + <> +
+ + +
+ + + ); +} diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..7d40c44 --- /dev/null +++ b/src/app/(app)/dashboard/page.tsx @@ -0,0 +1,67 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { PageHeader } from "@/components/page-header"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { MOCK_SESSION_USER } from "@/features/auth/mock"; +import { SummaryCardsSkeleton } from "@/features/dashboard/components/summary-cards-skeleton"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { formatMoney } from "@/shared/format"; + +export const metadata: Metadata = { title: tr.dashboard.title }; + +type SummaryCard = { + key: keyof typeof tr.dashboard.cards; + tone?: string; + amountMinor: number | null; +}; + +/** FAZ 14: gerçek özet verisi (gelir, gider, bakiye, kart borcu, net varlık). */ +async function getSummary(): Promise { + return [ + { key: "monthIncome", tone: "text-income", amountMinor: null }, + { key: "monthExpense", tone: "text-expense", amountMinor: null }, + { key: "monthRemaining", tone: "text-budget", amountMinor: null }, + { key: "totalBalance", amountMinor: null }, + { key: "cardDebt", tone: "text-expense", amountMinor: null }, + { key: "netWorth", amountMinor: null }, + ]; +} + +async function SummaryCards() { + const cards = await getSummary(); + return ( +
+ {cards.map((c) => ( + + + {tr.dashboard.cards[c.key]} + + {c.amountMinor === null ? "—" : formatMoney(c.amountMinor)} + + + {c.amountMinor === null ? ( + + {tr.dashboard.noData} + + ) : null} + + ))} +
+ ); +} + +export default function DashboardPage() { + const user = MOCK_SESSION_USER; + return ( + <> + + }> + + + + ); +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx new file mode 100644 index 0000000..0aa1c1b --- /dev/null +++ b/src/app/(app)/layout.tsx @@ -0,0 +1,29 @@ +import { AppHeader } from "@/components/layout/app-header"; +import { AppSidebar } from "@/components/layout/app-sidebar"; +import { BottomNav } from "@/components/layout/bottom-nav"; +import { QuickAddProvider } from "@/components/layout/quick-add"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { MOCK_SESSION_USER } from "@/features/auth/mock"; +import { MOCK_HOUSEHOLDS } from "@/features/households/mock"; +import { APP_VERSION } from "@/lib/version"; + +/** + * Oturum gerektiren alan: yan menü (masaüstü), üst bar, alt menü (mobil), hızlı ekle. + * FAZ 3: `const session = await requireSession()` — oturum yoksa /login. + */ +export default function AppLayout({ children }: { children: React.ReactNode }) { + const user = MOCK_SESSION_USER; + return ( + + + + + + {/* Mobilde alt menü yüksekliği kadar boşluk */} +
{children}
+
+ +
+
+ ); +} diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..a1c295c --- /dev/null +++ b/src/app/(auth)/layout.tsx @@ -0,0 +1,13 @@ +import { ThemeToggle } from "@/components/layout/theme-toggle"; + +/** Oturum gerektirmeyen ekranlar (yalnızca giriş). */ +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+ +
+ {children} +
+ ); +} diff --git a/src/app/(auth)/login/login-form.tsx b/src/app/(auth)/login/login-form.tsx new file mode 100644 index 0000000..3d0e25a --- /dev/null +++ b/src/app/(auth)/login/login-form.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { PasswordInput } from "@/components/password-input"; +import { Button } from "@/components/ui/button"; +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { tr } from "@/i18n/tr"; +import { loginSchema, type LoginInput } from "@/shared/schemas/auth"; + +export function LoginForm() { + const router = useRouter(); + const [formError, setFormError] = useState(null); + const form = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { identifier: "", password: "" }, + }); + + async function onSubmit(_values: LoginInput) { + setFormError(null); + // FAZ 3: looksLikeEmail(identifier) ? authClient.signIn.email : authClient.signIn.username + await new Promise((r) => setTimeout(r, 400)); + router.replace("/dashboard"); + } + + const submitting = form.formState.isSubmitting; + + return ( +
+ + ( + + {tr.auth.identifier} + + {fieldState.invalid ? : null} + + )} + /> + ( + + {tr.auth.password} + + {fieldState.invalid ? : null} + + )} + /> + {formError ? {formError} : null} + + +
+ ); +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..1c00480 --- /dev/null +++ b/src/app/(auth)/login/page.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from "next"; +import { Logo } from "@/components/brand/logo"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { tr } from "@/i18n/tr"; +import { LoginForm } from "./login-form"; + +export const metadata: Metadata = { title: tr.auth.loginTitle }; + +export default function LoginPage() { + return ( +
+ + + + {tr.auth.loginTitle} + {tr.auth.loginDescription} + + + + + +

{tr.auth.noSignUp}

+
+ ); +} diff --git a/src/app/apple-icon.png b/src/app/apple-icon.png new file mode 100644 index 0000000..d442934 Binary files /dev/null and b/src/app/apple-icon.png differ diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..084a220 --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { tr } from "@/i18n/tr"; + +/** Rota hata sınırı. İç detay gösterilmez; yalnızca sunucu loglarıyla eşleşen hata kodu (digest). */ +export default function ErrorPage({ error, retry }: { error: unknown; retry: () => void }) { + useEffect(() => { + console.error(error); + }, [error]); + + const digest = error instanceof Error && "digest" in error ? String(error.digest) : undefined; + + return ( +
+

{tr.errors.errorTitle}

+

{tr.errors.errorDescription}

+ {digest ? ( +

+ {tr.errors.reference}: {digest} +

+ ) : null} + +
+ ); +} diff --git a/src/app/favicon.ico b/src/app/favicon.ico index af98450..fbab8dd 100644 Binary files a/src/app/favicon.ico and b/src/app/favicon.ico differ diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx new file mode 100644 index 0000000..2f84f61 --- /dev/null +++ b/src/app/global-error.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { tr } from "@/i18n/tr"; +import "./globals.css"; + +/** Kök layout hataları için son çare sınırı (kendi / etiketini render eder). */ +export default function GlobalError({ retry }: { error: unknown; retry: () => void }) { + return ( + + +

{tr.errors.errorTitle}

+

{tr.errors.errorDescription}

+ + + + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 6a25b1b..ae2ff36 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -18,6 +18,14 @@ --color-sidebar-primary: var(--sidebar-primary); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar: var(--sidebar); + --color-income: var(--income); + --color-income-foreground: var(--income-foreground); + --color-expense: var(--expense); + --color-expense-foreground: var(--expense-foreground); + --color-budget: var(--budget); + --color-budget-foreground: var(--budget-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); --color-chart-5: var(--chart-5); --color-chart-4: var(--chart-4); --color-chart-3: var(--chart-3); @@ -48,73 +56,98 @@ --radius-4xl: calc(var(--radius) * 2.6); } +/* + * BÜTÇEM marka paleti — mavi ve beyaz tonları (oklch). Sabit renk kodu yalnızca burada. + * Anlamsal renkler: --income (gelir/yeşil), --expense (gider/kırmızı), + * --budget (bütçe-plan/mavi), --warning (uyarı/turuncu). Grafikler de bunları kullanır. + */ :root { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); + --background: oklch(0.99 0.004 250); + --foreground: oklch(0.22 0.04 262); --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); + --card-foreground: oklch(0.22 0.04 262); --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); + --popover-foreground: oklch(0.22 0.04 262); + --primary: oklch(0.49 0.17 258); + --primary-foreground: oklch(0.99 0.004 250); + --secondary: oklch(0.955 0.016 250); + --secondary-foreground: oklch(0.3 0.06 260); + --muted: oklch(0.955 0.012 250); + --muted-foreground: oklch(0.5 0.03 258); + --accent: oklch(0.935 0.03 250); + --accent-foreground: oklch(0.3 0.08 260); + --destructive: oklch(0.56 0.21 25); + --border: oklch(0.905 0.016 252); + --input: oklch(0.89 0.018 252); + --ring: oklch(0.6 0.15 258); + + --income: oklch(0.58 0.15 152); + --income-foreground: oklch(0.99 0.004 250); + --expense: oklch(0.56 0.21 25); + --expense-foreground: oklch(0.99 0.004 250); + --budget: oklch(0.55 0.17 258); + --budget-foreground: oklch(0.99 0.004 250); + --warning: oklch(0.68 0.17 55); + --warning-foreground: oklch(0.22 0.04 262); + + --chart-1: var(--income); + --chart-2: var(--budget); + --chart-3: var(--expense); + --chart-4: var(--warning); + --chart-5: oklch(0.65 0.08 230); --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --sidebar: oklch(0.975 0.008 250); + --sidebar-foreground: oklch(0.22 0.04 262); + --sidebar-primary: oklch(0.49 0.17 258); + --sidebar-primary-foreground: oklch(0.99 0.004 250); + --sidebar-accent: oklch(0.935 0.03 250); + --sidebar-accent-foreground: oklch(0.3 0.08 260); + --sidebar-border: oklch(0.905 0.016 252); + --sidebar-ring: oklch(0.6 0.15 258); } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --background: oklch(0.18 0.04 264); + --foreground: oklch(0.96 0.01 250); + --card: oklch(0.22 0.045 264); + --card-foreground: oklch(0.96 0.01 250); + --popover: oklch(0.22 0.045 264); + --popover-foreground: oklch(0.96 0.01 250); + --primary: oklch(0.76 0.12 245); + --primary-foreground: oklch(0.18 0.04 264); + --secondary: oklch(0.27 0.045 264); + --secondary-foreground: oklch(0.96 0.01 250); + --muted: oklch(0.26 0.04 264); + --muted-foreground: oklch(0.73 0.03 250); + --accent: oklch(0.3 0.06 262); + --accent-foreground: oklch(0.96 0.01 250); + --destructive: oklch(0.7 0.18 22); + --border: oklch(1 0 0 / 11%); + --input: oklch(1 0 0 / 16%); + --ring: oklch(0.7 0.12 245); + + --income: oklch(0.75 0.16 152); + --income-foreground: oklch(0.18 0.04 264); + --expense: oklch(0.7 0.18 22); + --expense-foreground: oklch(0.18 0.04 264); + --budget: oklch(0.73 0.13 245); + --budget-foreground: oklch(0.18 0.04 264); + --warning: oklch(0.79 0.15 62); + --warning-foreground: oklch(0.18 0.04 264); + + --chart-1: var(--income); + --chart-2: var(--budget); + --chart-3: var(--expense); + --chart-4: var(--warning); + --chart-5: oklch(0.7 0.07 230); + --sidebar: oklch(0.2 0.045 264); + --sidebar-foreground: oklch(0.96 0.01 250); + --sidebar-primary: oklch(0.76 0.12 245); + --sidebar-primary-foreground: oklch(0.18 0.04 264); + --sidebar-accent: oklch(0.28 0.055 262); + --sidebar-accent-foreground: oklch(0.96 0.01 250); + --sidebar-border: oklch(1 0 0 / 11%); + --sidebar-ring: oklch(0.7 0.12 245); } @layer base { @@ -128,3 +161,8 @@ @apply font-sans; } } + +/* Mobil alt menü + iOS ana ekran çentiği için güvenli alan */ +@utility pb-safe { + padding-bottom: env(safe-area-inset-bottom); +} diff --git a/src/app/icon.png b/src/app/icon.png new file mode 100644 index 0000000..0147bc1 Binary files /dev/null and b/src/app/icon.png differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b6038a9..6d12f99 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import { Providers } from "@/components/providers"; import { tr } from "@/i18n/tr"; import "./globals.css"; @@ -18,17 +19,31 @@ export const metadata: Metadata = { title: { default: tr.app.name, template: `%s · ${tr.app.name}` }, description: tr.app.tagline, applicationName: tr.app.name, + appleWebApp: { capable: true, title: tr.app.name, statusBarStyle: "default" }, + formatDetection: { telephone: false }, }; export const viewport: Viewport = { width: "device-width", initialScale: 1, + viewportFit: "cover", + // Manifest/tarayıcı çubuğu rengi CSS değişkeni okuyamaz; --background değerlerinin hex karşılığı. + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#fafcfe" }, + { media: "(prefers-color-scheme: dark)", color: "#081123" }, + ], }; export default function RootLayout({ children }: LayoutProps<"/">) { return ( - - {children} + + + {children} + ); } diff --git a/src/app/loading.tsx b/src/app/loading.tsx new file mode 100644 index 0000000..b48912b --- /dev/null +++ b/src/app/loading.tsx @@ -0,0 +1,14 @@ +import { Loader2 } from "lucide-react"; +import { tr } from "@/i18n/tr"; + +export default function Loading() { + return ( +
+ + {tr.common.loading} +
+ ); +} diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 0000000..6e4962f --- /dev/null +++ b/src/app/manifest.ts @@ -0,0 +1,26 @@ +import type { MetadataRoute } from "next"; +import { tr } from "@/i18n/tr"; + +/** PWA manifest: "Ana ekrana ekle" ile kurulabilir. İkonlar: npm run icons. */ +export default function manifest(): MetadataRoute.Manifest { + return { + id: "/", + name: tr.app.name, + short_name: tr.app.name, + description: tr.app.tagline, + lang: "tr-TR", + dir: "ltr", + start_url: "/dashboard", + scope: "/", + display: "standalone", + orientation: "portrait", + background_color: "#fafcfe", + theme_color: "#105bbe", + categories: ["finance", "productivity"], + icons: [ + { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" }, + { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" }, + { src: "/icons/maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" }, + ], + }; +} diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..140a90c --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,16 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { tr } from "@/i18n/tr"; + +export default function NotFound() { + return ( +
+

404

+

{tr.errors.notFoundTitle}

+

{tr.errors.notFoundDescription}

+ +
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 2b1508c..0abc6c8 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,15 +1,6 @@ -import { tr } from "@/i18n/tr"; -import { APP_VERSION } from "@/lib/version"; +import { redirect } from "next/navigation"; -/** Geçici açılış sayfası — Faz 2'de /login'e yönlendirme ile değişecek. */ +/** Uygulama her zaman login ile açılır. FAZ 3: oturum varsa /dashboard'a yönlenecek. */ export default function Home() { - return ( -
-

{tr.app.name}

-

{tr.app.setupDescription}

-

- {tr.app.version} {APP_VERSION} -

-
- ); + redirect("/login"); } diff --git a/src/components/admin/.gitkeep b/src/components/admin/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/components/brand/logo.tsx b/src/components/brand/logo.tsx new file mode 100644 index 0000000..d544b6c --- /dev/null +++ b/src/components/brand/logo.tsx @@ -0,0 +1,27 @@ +import Image from "next/image"; +import { cn } from "@/lib/utils"; +import { tr } from "@/i18n/tr"; + +/** + * Marka logosu: açık temada logo.svg, koyu temada logo-dark.svg (CSS ile seçilir, + * hydration uyumsuzluğu olmaz). Dosyalar public/brand/ altında. + */ +export function Logo({ className, priority }: { className?: string; priority?: boolean }) { + const common = { width: 120, height: 32, priority, unoptimized: true } as const; + return ( + + {tr.app.name} + + + ); +} diff --git a/src/components/layout/.gitkeep b/src/components/layout/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/components/layout/app-header.tsx b/src/components/layout/app-header.tsx new file mode 100644 index 0000000..d0e0d20 --- /dev/null +++ b/src/components/layout/app-header.tsx @@ -0,0 +1,31 @@ +import { SidebarTrigger } from "@/components/ui/sidebar"; +import type { SessionUser } from "@/features/auth/types"; +import type { HouseholdSummary } from "@/features/households/types"; +import { tr } from "@/i18n/tr"; +import { HouseholdSwitcher } from "./household-switcher"; +import { QuickAddButton } from "./quick-add"; +import { ThemeToggle } from "./theme-toggle"; +import { UserMenu } from "./user-menu"; + +/** Üst bar: menü düğmesi (masaüstü), hane seçici, hızlı ekle, tema ve kullanıcı menüsü. */ +export function AppHeader({ + user, + households, + version, +}: { + user: SessionUser; + households: HouseholdSummary[]; + version: string; +}) { + return ( +
+ + +
+ + + +
+
+ ); +} diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx new file mode 100644 index 0000000..4a4982e --- /dev/null +++ b/src/components/layout/app-sidebar.tsx @@ -0,0 +1,103 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { Logo } from "@/components/brand/logo"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, + SidebarRail, + useSidebar, +} from "@/components/ui/sidebar"; +import { tr } from "@/i18n/tr"; +import { ADMIN_NAV, MAIN_NAV, isActivePath, isAvailable, type NavItem } from "./nav-config"; + +function NavList({ items, pathname }: { items: NavItem[]; pathname: string }) { + const { setOpenMobile } = useSidebar(); + return ( + + {items.map((item) => { + const Icon = item.icon; + if (!isAvailable(item)) { + return ( + + + + {item.label} + + + {tr.common.comingSoon} + + + ); + } + const active = isActivePath(pathname, item.href); + return ( + + + setOpenMobile(false)} + > + + {item.label} + + + + ); + })} + + ); +} + +/** Masaüstünde sabit yan menü; mobilde "Daha fazla" ile açılan Sheet. */ +export function AppSidebar({ isAdmin, version }: { isAdmin: boolean; version: string }) { + const pathname = usePathname(); + return ( + + + + + + + + + {tr.nav.main} + + + + + {isAdmin ? ( + + {tr.nav.admin} + + + + + ) : null} + + + {tr.app.name} · {tr.app.version} {version} + + + + ); +} diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx new file mode 100644 index 0000000..ce8bc15 --- /dev/null +++ b/src/components/layout/bottom-nav.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { Menu, Plus } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useSidebar } from "@/components/ui/sidebar"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; +import { BOTTOM_NAV, isActivePath, isAvailable, type NavItem } from "./nav-config"; +import { useQuickAdd } from "./quick-add"; + +const itemClass = + "flex min-h-14 flex-1 flex-col items-center justify-center gap-0.5 text-[0.7rem] font-medium outline-none focus-visible:bg-muted"; + +function BottomNavLink({ item, pathname }: { item: NavItem; pathname: string }) { + const Icon = item.icon; + if (!isAvailable(item)) { + return ( + + + {item.label} + + ); + } + const active = isActivePath(pathname, item.href); + return ( + + + {item.label} + + ); +} + +/** Mobil alt gezinme çubuğu (md altı). */ +export function BottomNav() { + const pathname = usePathname(); + const { openQuickAdd } = useQuickAdd(); + const { setOpenMobile } = useSidebar(); + return ( + + ); +} diff --git a/src/components/layout/household-switcher.tsx b/src/components/layout/household-switcher.tsx new file mode 100644 index 0000000..733738f --- /dev/null +++ b/src/components/layout/household-switcher.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { House } from "lucide-react"; +import { useState } from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { HouseholdSummary } from "@/features/households/types"; +import { tr } from "@/i18n/tr"; + +/** Üst bar aktif hane seçici. FAZ 5: seçim sunucuda (cookie) saklanacak. */ +export function HouseholdSwitcher({ households }: { households: HouseholdSummary[] }) { + const [value, setValue] = useState(households[0]?.id); + const current = households.find((h) => h.id === value); + if (households.length === 0) return null; + return ( + + ); +} diff --git a/src/components/layout/nav-config.ts b/src/components/layout/nav-config.ts new file mode 100644 index 0000000..1ede442 --- /dev/null +++ b/src/components/layout/nav-config.ts @@ -0,0 +1,59 @@ +import { + ArrowLeftRight, + CalendarClock, + ChartColumn, + Coins, + House, + LayoutDashboard, + Package, + Settings, + ShoppingCart, + Target, + UserCog, + Users, + Wallet, + type LucideIcon, +} from "lucide-react"; +import { tr } from "@/i18n/tr"; + +export type NavItem = { + href: string; + label: string; + icon: LucideIcon; + /** Sayfanın ekleneceği faz; mevcut fazdan büyükse "Yakında" olarak pasif gösterilir. */ + phase: number; +}; + +/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */ +export const CURRENT_PHASE = 2; + +export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE; + +export const MAIN_NAV: NavItem[] = [ + { href: "/dashboard", label: tr.nav.dashboard, icon: LayoutDashboard, phase: 2 }, + { href: "/islemler", label: tr.nav.transactions, icon: ArrowLeftRight, phase: 6 }, + { href: "/hesaplar", label: tr.nav.accounts, icon: Wallet, phase: 6 }, + { href: "/planli", label: tr.nav.planned, icon: CalendarClock, phase: 7 }, + { href: "/cari", label: tr.nav.contacts, icon: Users, phase: 8 }, + { href: "/birikim", label: tr.nav.savings, icon: Coins, phase: 9 }, + { href: "/butce", label: tr.nav.budget, icon: Target, phase: 10 }, + { href: "/stok", label: tr.nav.stock, icon: Package, phase: 11 }, + { href: "/alisveris", label: tr.nav.shopping, icon: ShoppingCart, phase: 13 }, + { href: "/raporlar", label: tr.nav.reports, icon: ChartColumn, phase: 14 }, + { href: "/ayarlar", label: tr.nav.settings, icon: Settings, phase: 5 }, +]; + +export const ADMIN_NAV: NavItem[] = [ + { href: "/admin/kullanicilar", label: tr.nav.adminUsers, icon: UserCog, phase: 2 }, + { href: "/admin/haneler", label: tr.nav.adminHouseholds, icon: House, phase: 5 }, +]; + +/** Mobil alt menü: ortada Hızlı ekle, sağda "Daha fazla" (yan menüyü açar). */ +export const BOTTOM_NAV = { + left: [MAIN_NAV[0]!, MAIN_NAV[1]!], + right: [MAIN_NAV[6]!], +}; + +export function isActivePath(pathname: string, href: string) { + return pathname === href || pathname.startsWith(`${href}/`); +} diff --git a/src/components/layout/quick-add.tsx b/src/components/layout/quick-add.tsx new file mode 100644 index 0000000..1b8aa46 --- /dev/null +++ b/src/components/layout/quick-add.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { ArrowDownLeft, ArrowLeftRight, ArrowUpRight, Plus } from "lucide-react"; +import { createContext, use, useState } from "react"; +import { toast } from "sonner"; +import { ResponsiveDialog } from "@/components/responsive-dialog"; +import { Button } from "@/components/ui/button"; +import { tr } from "@/i18n/tr"; +import { cn } from "@/lib/utils"; + +type QuickAddKind = "expense" | "income" | "transfer"; + +const QuickAddContext = createContext<{ openQuickAdd: () => void } | null>(null); + +export function useQuickAdd() { + const ctx = use(QuickAddContext); + if (!ctx) throw new Error("useQuickAdd, QuickAddProvider içinde kullanılmalı"); + return ctx; +} + +const CHOICES: { + kind: QuickAddKind; + label: string; + hint: string; + icon: typeof Plus; + tone: string; +}[] = [ + { + kind: "expense", + label: tr.quickAdd.expense, + hint: tr.quickAdd.expenseHint, + icon: ArrowUpRight, + tone: "text-expense bg-expense/10", + }, + { + kind: "income", + label: tr.quickAdd.income, + hint: tr.quickAdd.incomeHint, + icon: ArrowDownLeft, + tone: "text-income bg-income/10", + }, + { + kind: "transfer", + label: tr.quickAdd.transfer, + hint: tr.quickAdd.transferHint, + icon: ArrowLeftRight, + tone: "text-budget bg-budget/10", + }, +]; + +/** Hızlı ekle (gider / gelir / transfer) — her ekrandan açılabilir. */ +export function QuickAddProvider({ children }: { children: React.ReactNode }) { + const [open, setOpen] = useState(false); + + function choose(kind: QuickAddKind) { + setOpen(false); + // FAZ 6: ilgili işlem formu açılacak. + toast.info(`${CHOICES.find((c) => c.kind === kind)?.label}: ${tr.quickAdd.notYet}`); + } + + return ( + setOpen(true) }}> + {children} + +
+ {CHOICES.map(({ kind, label, hint, icon: Icon, tone }) => ( + + ))} +
+
+
+ ); +} + +/** Masaüstü üst bar düğmesi. */ +export function QuickAddButton({ className }: { className?: string }) { + const { openQuickAdd } = useQuickAdd(); + return ( + + ); +} diff --git a/src/components/layout/theme-toggle.tsx b/src/components/layout/theme-toggle.tsx new file mode 100644 index 0000000..ec35167 --- /dev/null +++ b/src/components/layout/theme-toggle.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { Monitor, Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { tr } from "@/i18n/tr"; + +const OPTIONS = [ + { value: "light", label: tr.theme.light, icon: Sun }, + { value: "dark", label: tr.theme.dark, icon: Moon }, + { value: "system", label: tr.theme.system, icon: Monitor }, +] as const; + +/** Açık / Koyu / Sistem tema seçici. İkon CSS ile değişir (hydration güvenli). */ +export function ThemeToggle() { + const { theme, setTheme } = useTheme(); + return ( + + + + + + {tr.theme.label} + + {OPTIONS.map(({ value, label, icon: Icon }) => ( + + + {label} + + ))} + + + + ); +} diff --git a/src/components/layout/user-menu.tsx b/src/components/layout/user-menu.tsx new file mode 100644 index 0000000..3987572 --- /dev/null +++ b/src/components/layout/user-menu.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { LogOut } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { SessionUser } from "@/features/auth/types"; +import { tr } from "@/i18n/tr"; + +export function initials(name: string) { + return name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((p) => p.charAt(0).toLocaleUpperCase("tr-TR")) + .join(""); +} + +/** Kullanıcı menüsü: ad, rol, sürüm ve Çıkış yap. */ +export function UserMenu({ user, version }: { user: SessionUser; version: string }) { + const router = useRouter(); + + function logout() { + // FAZ 3: authClient.signOut() sonrası yönlendirme. + toast.success(tr.userMenu.loggedOut); + router.replace("/login"); + } + + return ( + + + + + + + {user.name} + @{user.username} + + {tr.roles[user.role]} + + + + + + {tr.userMenu.logout} + + +
+ {tr.app.name} · {tr.app.version} {version} +
+
+
+ ); +} diff --git a/src/components/page-header.tsx b/src/components/page-header.tsx new file mode 100644 index 0000000..c3fe65d --- /dev/null +++ b/src/components/page-header.tsx @@ -0,0 +1,20 @@ +/** Sayfa başlığı + açıklama + sağda isteğe bağlı aksiyonlar. */ +export function PageHeader({ + title, + description, + actions, +}: { + title: string; + description?: string; + actions?: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {description ?

{description}

: null} +
+ {actions ?
{actions}
: null} +
+ ); +} diff --git a/src/components/password-input.tsx b/src/components/password-input.tsx new file mode 100644 index 0000000..cccb94a --- /dev/null +++ b/src/components/password-input.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { Eye, EyeOff } from "lucide-react"; +import { useState } from "react"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@/components/ui/input-group"; +import { tr } from "@/i18n/tr"; + +/** Göster/gizle düğmeli şifre alanı. */ +export function PasswordInput(props: Omit, "type">) { + const [visible, setVisible] = useState(false); + const label = visible ? tr.common.hidePassword : tr.common.showPassword; + return ( + + + + setVisible((v) => !v)} + > + {visible ? : } + + + + ); +} diff --git a/src/components/providers.tsx b/src/components/providers.tsx new file mode 100644 index 0000000..204b893 --- /dev/null +++ b/src/components/providers.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { ThemeProvider } from "next-themes"; +import { Toaster } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; + +/** Kök istemci sağlayıcıları: tema (açık/koyu/sistem), tooltip ve bildirimler. */ +export function Providers({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + + ); +} diff --git a/src/components/responsive-dialog.tsx b/src/components/responsive-dialog.tsx new file mode 100644 index 0000000..adec77b --- /dev/null +++ b/src/components/responsive-dialog.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { cn } from "@/lib/utils"; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + title: React.ReactNode; + description?: React.ReactNode; + children: React.ReactNode; + footer?: React.ReactNode; + className?: string; +}; + +/** + * Masaüstünde Dialog, mobilde (neredeyse) tam ekran alttan açılan Drawer. + * Formlar bu bileşenle açılır (Bölüm 4: "formlar mobilde tam ekran Sheet/Drawer"). + */ +export function ResponsiveDialog({ + open, + onOpenChange, + title, + description, + children, + footer, + className, +}: Props) { + const isMobile = useIsMobile(); + + if (isMobile) { + return ( + + + + {title} + {description ? {description} : null} + +
{children}
+ {footer ? {footer} : null} +
+
+ ); + } + + return ( + + + + {title} + {description ? {description} : null} + +
{children}
+ {footer ? {footer} : null} +
+
+ ); +} diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..882ce4c --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,182 @@ +"use client"; + +import * as React from "react"; +import { cn } from "cn"; +import { AlertDialog as AlertDialogPrimitive } from "radix-ui"; + +import { Button } from "@/components/ui/button"; + +function AlertDialog({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function AlertDialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm"; +}) { + return ( + + + + + ); +} + +function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ); +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +}; diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..af96733 --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,95 @@ +"use client"; + +import * as React from "react"; +import { cn } from "cn"; +import { Avatar as AvatarPrimitive } from "radix-ui"; + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg"; +}) { + return ( + + ); +} + +function AvatarImage({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className, + )} + {...props} + /> + ); +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className, + )} + {...props} + /> + ); +} + +export { Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarBadge }; diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..6a15d9b --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,44 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "cn"; +import { Slot } from "radix-ui"; + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span"; + + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..425a48a --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,87 @@ +import * as React from "react"; +import { cn } from "cn"; + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className, + )} + {...props} + /> + ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..31dfdd7 --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +"use client"; + +import * as React from "react"; +import { cn } from "cn"; +import { Checkbox as CheckboxPrimitive } from "radix-ui"; +import { CheckIcon } from "lucide-react"; + +function Checkbox({ className, ...props }: React.ComponentProps) { + return ( + + + + + + ); +} + +export { Checkbox }; diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..cf47226 --- /dev/null +++ b/src/components/ui/collapsible.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { Collapsible as CollapsiblePrimitive } from "radix-ui"; + +function Collapsible({ ...props }: React.ComponentProps) { + return ; +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps) { + return ; +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..15afa8f --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,145 @@ +"use client"; + +import * as React from "react"; +import { cn } from "cn"; +import { Dialog as DialogPrimitive } from "radix-ui"; + +import { Button } from "@/components/ui/button"; +import { XIcon } from "lucide-react"; + +function Dialog({ ...props }: React.ComponentProps) { + return ; +} + +function DialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DialogClose({ ...props }: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean; +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ); +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx new file mode 100644 index 0000000..af68d7b --- /dev/null +++ b/src/components/ui/drawer.tsx @@ -0,0 +1,119 @@ +"use client"; + +import * as React from "react"; +import { cn } from "cn"; +import { Drawer as DrawerPrimitive } from "vaul"; + +function Drawer({ ...props }: React.ComponentProps) { + return ; +} + +function DrawerTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DrawerPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DrawerClose({ ...props }: React.ComponentProps) { + return ; +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DrawerContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + +
+ {children} + + + ); +} + +function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DrawerTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +}; diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..af34d99 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,249 @@ +"use client"; + +import * as React from "react"; +import { cn } from "cn"; +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; +import { CheckIcon, ChevronRightIcon } from "lucide-react"; + +function DropdownMenu({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuContent({ + className, + align = "start", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuGroup({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ); +} + +function DropdownMenuSub({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +}; diff --git a/src/components/ui/field.tsx b/src/components/ui/field.tsx new file mode 100644 index 0000000..8b63f51 --- /dev/null +++ b/src/components/ui/field.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { useMemo } from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "cn"; + +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className, + )} + {...props} + /> + ); +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ); +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +const fieldVariants = cva("group/field flex w-full gap-2 data-[invalid=true]:text-destructive", { + variants: { + orientation: { + vertical: "flex-col *:w-full [&>.sr-only]:w-auto", + horizontal: + "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + responsive: + "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + }, + }, + defaultVariants: { + orientation: "vertical", + }, +}); + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function FieldLabel({ className, ...props }: React.ComponentProps) { + return ( +