diff --git a/data/app.db b/data/app.db
index 7146f0f..b775651 100644
Binary files a/data/app.db and b/data/app.db differ
diff --git a/data/yokatlas.db b/data/yokatlas.db
index 177f5e8..9516148 100644
Binary files a/data/yokatlas.db and b/data/yokatlas.db differ
diff --git a/data/yokatlas.db-shm b/data/yokatlas.db-shm
index 069b7fc..fe9ac28 100644
Binary files a/data/yokatlas.db-shm and b/data/yokatlas.db-shm differ
diff --git a/data/yokatlas.db-wal b/data/yokatlas.db-wal
index 21785dd..e69de29 100644
Binary files a/data/yokatlas.db-wal and b/data/yokatlas.db-wal differ
diff --git a/next.config.ts b/next.config.ts
index 5a55cdb..aacb5a6 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,6 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
+ // PPR: statik kabuk anında servis edilir, dinamik parçalar (UserNav vb.)
+ // Suspense sınırlarının içinde akar. Bkz. docs/cacheComponents.md.
+ cacheComponents: true,
output: "standalone",
serverExternalPackages: ["better-sqlite3", "iyzipay", "@libsql/client"],
async redirects() {
diff --git a/scripts/db-index.ts b/scripts/db-index.ts
new file mode 100644
index 0000000..ff5252a
--- /dev/null
+++ b/scripts/db-index.ts
@@ -0,0 +1,36 @@
+// Sorgu yolundaki ifade indexi + WAL bakımı. Bir kez çalıştırılır
+// (pnpm tsx scripts/db-index.ts); ingest/refresh sonrası da güvenle
+// tekrar çalıştırılabilir (IF NOT EXISTS).
+//
+// src/lib/db.ts tüm aralık sorgularını COALESCE(sira2025, sira2024)
+// üzerinden yapar; SQLite ifade indexleri METİNSEL eşleştiği için buradaki
+// ifade oradaki EFEKTIF_SIRA sabitiyle birebir aynı olmalıdır. Bu index
+// olmadan her sorgu tur-partisyonunu tarayıp temp B-tree ile sıralıyordu.
+
+import path from "node:path";
+import Database from "better-sqlite3";
+
+const db = new Database(path.join(process.cwd(), "data", "yokatlas.db"));
+
+db.exec(`
+ CREATE INDEX IF NOT EXISTS idx_programs_tur_onlisans_efektif
+ ON programs (tur, onlisans, COALESCE(sira2025, sira2024));
+`);
+
+// Uygulama DB'yi readonly açtığı için checkpoint yapamaz; şişen WAL'i
+// (okuma başına ekstra maliyet) burada dosyaya geri yaz ve sıfırla.
+db.pragma("wal_checkpoint(TRUNCATE)");
+
+const plan = db
+ .prepare(
+ `EXPLAIN QUERY PLAN
+ SELECT id FROM programs
+ WHERE tur = ? AND onlisans = ?
+ AND COALESCE(sira2025, sira2024) IS NOT NULL
+ AND COALESCE(sira2025, sira2024) BETWEEN ? AND ?
+ ORDER BY COALESCE(sira2025, sira2024) ASC LIMIT 10`,
+ )
+ .all("SAYISAL", 0, 1000, 50000);
+
+console.log("Sorgu planı:", JSON.stringify(plan, null, 2));
+db.close();
diff --git a/scripts/refresh.ts b/scripts/refresh.ts
index 683f3da..d6b2ee0 100644
--- a/scripts/refresh.ts
+++ b/scripts/refresh.ts
@@ -119,6 +119,14 @@ async function main() {
`);
}
+ // Sorgu yolunun kullandığı ifade indexi; ifade src/lib/db.ts EFEKTIF_SIRA
+ // ile birebir aynı olmalı (bkz. scripts/db-index.ts). sira2025 kolonu
+ // yukarıda garanti edildikten sonra kurulur.
+ db.exec(`
+ CREATE INDEX IF NOT EXISTS idx_programs_tur_onlisans_efektif
+ ON programs (tur, onlisans, COALESCE(sira2025, sira2024));
+ `);
+
const update = db.prepare(`
UPDATE programs SET sira2025 = ?, puan2025 = ?, kontenjan2025 = ?, yerlesen2025 = ?
WHERE id = ?
diff --git a/src/app/globals.css b/src/app/globals.css
index 01a12f8..c5c418e 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -162,6 +162,38 @@
}
}
+/* Landing bölümlerinin görünüme girerken belirmesi (bkz. landing/reveal.tsx).
+ İçerik SSR'da GÖRÜNÜR; gizleme yalnızca script çalışan tarayıcılarda ve
+ IO sınıfı ekleyene kadar geçerli — JS'siz istemcide sayfa asla boş kalmaz. */
+@media (scripting: enabled) {
+ .kt-reveal {
+ opacity: 0;
+ translate: 0 var(--reveal-y, 28px);
+ transition:
+ opacity 0.55s cubic-bezier(0.21, 0.47, 0.32, 0.98),
+ translate 0.55s cubic-bezier(0.21, 0.47, 0.32, 0.98);
+ /* Emniyet: hydration hiç gelmezse 3 sn sonra içerik yine de belirir */
+ animation: kt-reveal-emniyet 0.5s ease-out 3s forwards;
+ }
+ .kt-reveal.kt-reveal-in {
+ opacity: 1;
+ translate: 0 0;
+ }
+ @media (prefers-reduced-motion: reduce) {
+ .kt-reveal {
+ translate: 0 0;
+ transition: opacity 0.25s ease-out;
+ }
+ }
+}
+
+@keyframes kt-reveal-emniyet {
+ to {
+ opacity: 1;
+ translate: 0 0;
+ }
+}
+
@layer base {
* {
@apply border-border outline-ring/50;
@@ -180,11 +212,7 @@
}
html {
@apply font-sans;
- scroll-behavior: smooth;
- }
- @media (prefers-reduced-motion: reduce) {
- html {
- scroll-behavior: auto;
- }
+ /* scroll-behavior: smooth KULLANMA — Lenis (ScrollFlow, anchors: true)
+ kaydırmayı zaten yumuşatıyor; ikisi birlikte çift yumuşatma yapar. */
}
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 25fa2ba..930699a 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -4,7 +4,7 @@ import localFont from "next/font/local";
import { Toaster } from "@/components/ui/sonner";
import { SiteHeader } from "@/components/site-header";
import { SiteTopBanner } from "@/components/site-top-banner";
-import { ListeCekmecesi } from "@/components/manuel-liste/liste-cekmecesi";
+import { ListeCekmecesiLazy } from "@/components/manuel-liste/liste-cekmecesi-lazy";
import { HeaderGate } from "@/components/header-gate";
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
import { DevPanel } from "@/components/dev/dev-panel";
@@ -31,6 +31,8 @@ const clarityCity = localFont({
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
+ // Yalnızca markdown/kod bloklarında kullanılıyor; landing'de preload israfı
+ preload: false,
});
const bricolage = Bricolage_Grotesque({
@@ -81,7 +83,7 @@ export default function RootLayout({
{children}
{/* Manuel 24'lük liste çekmecesi + "+" uçuş katmanı (navbar'dan açılır) */}
-
+
{/* Dev süperadmin paneli — prod build'de hiç render edilmez */}
{process.env.NODE_ENV !== "production" ? : null}
diff --git a/src/app/listem/page.tsx b/src/app/listem/page.tsx
index 6134cc0..6e22b1c 100644
--- a/src/app/listem/page.tsx
+++ b/src/app/listem/page.tsx
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
-import { eq } from "drizzle-orm";
+import { asc, eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import {
AlertTriangle,
@@ -27,7 +27,7 @@ import { RaporListesi } from "@/components/rapor-listesi";
import { ViewportPortal } from "@/components/viewport-portal";
import { PagePixelDivider } from "@/components/pixel-decor";
import { raporMaskele, ACIK_SATIR } from "@/lib/rapor-maske";
-import { SohbetClient } from "./sohbet-client";
+import { SohbetClient, type Mesaj } from "./sohbet-client";
import { RevizyonKutusu } from "./revizyon-kutusu";
export const metadata: Metadata = {
@@ -43,18 +43,32 @@ export default async function ListemPage({
}: {
searchParams: Promise<{ acildi?: string }>;
}) {
- await verifySession("/listem");
- const user = await getCurrentUser();
+ const session = await verifySession("/listem");
+ // Kullanıcı satırı, ?acildi parametresi ve rapor birbirinden bağımsız —
+ // seri await zinciri yerine tek turda çöz.
+ const [user, { acildi }, satir, sohbetGecmisi] = await Promise.all([
+ getCurrentUser(),
+ searchParams,
+ appDb.query.reports.findFirst({
+ where: eq(schema.reports.userId, session.user.id),
+ }),
+ // Sohbet geçmişi burada gelirse client mount sonrası /api/soru turu atmaz
+ appDb
+ .select({
+ id: schema.chatMessages.id,
+ role: schema.chatMessages.role,
+ content: schema.chatMessages.content,
+ })
+ .from(schema.chatMessages)
+ .where(eq(schema.chatMessages.userId, session.user.id))
+ .orderBy(asc(schema.chatMessages.createdAt))
+ .limit(100),
+ ]);
if (!user) redirect("/giris?callback=/listem");
// ?acildi=1: ödeme dönüşü — kilidin kalktığı tek seferlik açılış deneyimi
- const { acildi } = await searchParams;
const kilitAcilisi = Boolean(user.hasPaket && acildi === "1");
- const satir = await appDb.query.reports.findFirst({
- where: eq(schema.reports.userId, user.id),
- });
-
// Liste yoksa önce sihirbazdan oluşturulmalı
if (!satir?.result) {
return (
@@ -239,7 +253,10 @@ export default async function ListemPage({
{user.hasPaket ? (
-
+
) : (
@@ -317,6 +334,7 @@ export default async function ListemPage({
tattırır (kalan kredi biterse composer paket CTA'sına döner). */}
diff --git a/src/app/listem/sohbet-client.tsx b/src/app/listem/sohbet-client.tsx
index e26193f..70e06e7 100644
--- a/src/app/listem/sohbet-client.tsx
+++ b/src/app/listem/sohbet-client.tsx
@@ -23,7 +23,7 @@ import { ScrollButton } from "@/components/ui/scroll-button";
import { DotsLoader } from "@/components/ui/loader";
import { Skeleton } from "@/components/ui/skeleton";
-interface Mesaj {
+export interface Mesaj {
id: string;
role: "user" | "assistant";
content: string;
@@ -42,19 +42,25 @@ const ORNEK_SORULAR = [
*/
export function SohbetClient({
baslangicKredi,
+ initialMesajlar,
className,
}: {
baslangicKredi: number;
+ /** Sunucu tarafında çekilmiş geçmiş; verilirse mount fetch'i atlanır */
+ initialMesajlar?: Mesaj[];
/** Boyutlandırma override'ı (ör. alt popup'ta h-full) */
className?: string;
}) {
- const [mesajlar, setMesajlar] = useState
([]);
+ const [mesajlar, setMesajlar] = useState(initialMesajlar ?? []);
const [girdi, setGirdi] = useState("");
const [bekliyor, setBekliyor] = useState(false);
- const [yuklendi, setYuklendi] = useState(false);
+ const [yuklendi, setYuklendi] = useState(initialMesajlar != null);
const [kredi, setKredi] = useState(baslangicKredi);
useEffect(() => {
+ // Geçmiş sunucudan geldiyse (listem sayfası) ekstra tur atma;
+ // popup gibi geç mount olan yerlerde eski davranış sürer.
+ if (initialMesajlar != null) return;
fetch("/api/soru")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
@@ -63,7 +69,7 @@ export function SohbetClient({
})
.catch(() => {})
.finally(() => setYuklendi(true));
- }, []);
+ }, [initialMesajlar]);
async function gonder(metin: string) {
const mesaj = metin.trim();
diff --git a/src/app/odeme/sonuc/loading.tsx b/src/app/odeme/sonuc/loading.tsx
new file mode 100644
index 0000000..66aaee5
--- /dev/null
+++ b/src/app/odeme/sonuc/loading.tsx
@@ -0,0 +1,19 @@
+import { Skeleton } from "@/components/ui/skeleton";
+
+// /odeme/sonuc iyzico doğrulaması bekler — ortalanmış sonuç kartı iskeleti.
+export default function Loading() {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/paket/loading.tsx b/src/app/paket/loading.tsx
new file mode 100644
index 0000000..4c967c4
--- /dev/null
+++ b/src/app/paket/loading.tsx
@@ -0,0 +1,23 @@
+import { Skeleton } from "@/components/ui/skeleton";
+
+// /paket dinamik (kullanıcı kredisi) — statik kabuk için sayfa geometrisine
+// uygun iskelet: ortalanmış başlık bloğu + paket kartı şeridi.
+export default function Loading() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/paket/page.tsx b/src/app/paket/page.tsx
index 627e862..71f112b 100644
--- a/src/app/paket/page.tsx
+++ b/src/app/paket/page.tsx
@@ -47,8 +47,7 @@ export default async function PaketPage({
}: {
searchParams: Promise<{ hata?: string }>;
}) {
- const u = await getCurrentUser();
- const { hata } = await searchParams;
+ const [u, { hata }] = await Promise.all([getCurrentUser(), searchParams]);
// Ödeme callback'i doğrulama hatasında buraya ?hata=token|siparis ile döner
const odemeHatasi = hata === "token" || hata === "siparis";
diff --git a/src/app/rapor/yazdir/loading.tsx b/src/app/rapor/yazdir/loading.tsx
new file mode 100644
index 0000000..87072d2
--- /dev/null
+++ b/src/app/rapor/yazdir/loading.tsx
@@ -0,0 +1,21 @@
+import { Skeleton } from "@/components/ui/skeleton";
+
+// /rapor/yazdir rapor sorgusunu bekler — belge düzeninde iskelet.
+export default function Loading() {
+ return (
+
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/rapor/yazdir/page.tsx b/src/app/rapor/yazdir/page.tsx
index 504174f..8071219 100644
--- a/src/app/rapor/yazdir/page.tsx
+++ b/src/app/rapor/yazdir/page.tsx
@@ -20,14 +20,16 @@ const DILIM_LABEL: Record = {
};
export default async function YazdirPage() {
- await verifySession("/rapor/yazdir");
- const user = await getCurrentUser();
+ const session = await verifySession("/rapor/yazdir");
+ // Kullanıcı satırı ile rapor sorgusu bağımsız — paralel çöz
+ const [user, satir] = await Promise.all([
+ getCurrentUser(),
+ appDb.query.reports.findFirst({
+ where: eq(schema.reports.userId, session.user.id),
+ }),
+ ]);
if (!user) redirect("/giris");
if (!user.hasPaket) redirect("/paket");
-
- const satir = await appDb.query.reports.findFirst({
- where: eq(schema.reports.userId, user.id),
- });
if (!satir?.result) redirect("/sonuc");
const rapor = satir.result as RaporSonuc;
diff --git a/src/app/sonuc/page.tsx b/src/app/sonuc/page.tsx
index a096e38..4e0fb37 100644
--- a/src/app/sonuc/page.tsx
+++ b/src/app/sonuc/page.tsx
@@ -71,14 +71,18 @@ export default async function SonucPage({
);
}
- // Sihirbaz için: kullanıcı durumu, kayıtlı rapor ve dinamik facet'ler
+ // Sihirbaz için: kullanıcı durumu, kayıtlı rapor ve dinamik facet'ler.
+ // Rapor sorgusu yalnızca session.user.id'ye bağlı — getCurrentUser'ı
+ // beklemeden paralel çalışır (seri DB zinciri kurma).
const session = await getSession();
- const user = session ? await getCurrentUser() : null;
- const raporSatiri = user
- ? await appDb.query.reports.findFirst({
- where: eq(schema.reports.userId, user.id),
- })
- : null;
+ const [user, raporSatiri] = session
+ ? await Promise.all([
+ getCurrentUser(),
+ appDb.query.reports.findFirst({
+ where: eq(schema.reports.userId, session.user.id),
+ }),
+ ])
+ : [null, null];
const facetler = rankWindowFacets(sira, turKey);
// AI'sız ham liste: yalnızca sıralamayla ulaşılabilen programlar (dilimli)
diff --git a/src/app/sonuc/sihirbaz-modal.tsx b/src/app/sonuc/sihirbaz-modal.tsx
index 9c3f445..c4dec6d 100644
--- a/src/app/sonuc/sihirbaz-modal.tsx
+++ b/src/app/sonuc/sihirbaz-modal.tsx
@@ -6,7 +6,7 @@ import {
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
-import { SihirbazAdimlar } from "@/components/sihirbaz-adimlar";
+import { SihirbazAdimlarLazy } from "@/components/sihirbaz-adimlar-lazy";
import type { SihirbazFacetleri } from "@/lib/db";
import type { SihirbazSecimleri } from "@/lib/sihirbaz";
@@ -40,7 +40,7 @@ export function SihirbazModal({
Sıralamana uygun 24 tercihlik listeni oluştur.
- import("@/app/listem/sohbet-client").then((m) => m.SohbetClient),
+ {
+ ssr: false,
+ loading: () => (
+
+ Sohbet yükleniyor…
+
+ ),
+ },
+);
+
export function SohbetPopup({ kredi }: { kredi: number }) {
const [acik, setAcik] = useState(false);
const azMotion = useReducedMotion();
diff --git a/src/components/hero-form.tsx b/src/components/hero-form.tsx
index 2619cd9..68c5a08 100644
--- a/src/components/hero-form.tsx
+++ b/src/components/hero-form.tsx
@@ -11,7 +11,10 @@ import {
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
-import { SihirbazAdimlar } from "@/components/sihirbaz-adimlar";
+import {
+ SihirbazAdimlarLazy,
+ preloadSihirbazAdimlar,
+} from "@/components/sihirbaz-adimlar-lazy";
import { authClient } from "@/lib/auth-client";
import type { SihirbazFacetleri } from "@/lib/db";
import { SIHIRBAZ_STORAGE_KEY, type SihirbazSecimleri } from "@/lib/sihirbaz";
@@ -33,9 +36,8 @@ export function HeroForm() {
const [yukleniyor, setYukleniyor] = useState(false);
const [modalAcik, setModalAcik] = useState(false);
const [hata, setHata] = useState(null);
+ const [girisli, setGirisli] = useState(false);
const router = useRouter();
- const { data: oturum } = authClient.useSession();
- const girisli = Boolean(oturum?.user);
// Yazarken binlik ayracıyla biçimle (85000 → 85.000)
function siralamaDegisti(ham: string) {
@@ -55,9 +57,15 @@ export function HeroForm() {
}
setYukleniyor(true);
try {
- const res = await fetch(`/api/facetler?sira=${value}&tur=${tur}`);
+ // Oturum bilgisi mount'ta değil burada, facet isteğiyle PARALEL çözülür:
+ // landing ziyareti başına gereksiz auth turu atılmaz (sayfa statik kalır).
+ const [res, oturum] = await Promise.all([
+ fetch(`/api/facetler?sira=${value}&tur=${tur}`),
+ authClient.getSession().catch(() => null),
+ ]);
if (!res.ok) throw new Error();
const veri = (await res.json()) as { facetler: SihirbazFacetleri };
+ setGirisli(Boolean(oturum?.data?.user));
setSira(value);
setFacetler(veri.facetler);
setModalAcik(true);
@@ -127,6 +135,7 @@ export function HeroForm() {
placeholder="YKS başarı sıralaman (ör. 85.000)"
value={siralama}
onChange={(e) => siralamaDegisti(e.target.value)}
+ onFocus={preloadSihirbazAdimlar}
aria-invalid={hata ? true : undefined}
aria-describedby={hata ? "siralama-hata" : undefined}
// flex-1 yalnızca yatay dizilimde (sm+): mobilde form flex-col olduğu
@@ -162,7 +171,7 @@ export function HeroForm() {
Sıralamana uygun seçimlerini yap, tercih planına geç.
{facetler ? (
- (null);
+ const [gorunur, setGorunur] = useState(false);
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el || typeof IntersectionObserver === "undefined") {
+ setGorunur(true);
+ return;
+ }
+ const io = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((e) => e.isIntersecting)) {
+ setGorunur(true);
+ io.disconnect();
+ }
+ },
+ { rootMargin: "0px 0px -80px 0px" },
+ );
+ io.observe(el);
+ return () => io.disconnect();
+ }, []);
+
return (
-
{children}
-
+
);
}
diff --git a/src/components/manuel-liste/liste-cekmecesi-lazy.tsx b/src/components/manuel-liste/liste-cekmecesi-lazy.tsx
new file mode 100644
index 0000000..0946dc6
--- /dev/null
+++ b/src/components/manuel-liste/liste-cekmecesi-lazy.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+// Çekmece kapalıyken hiçbir şey çizmez; drag/Reorder kodunun baseline
+// bundle'a girmemesi için ssr'siz dynamic import (layout server component
+// olduğundan bu sarmalayıcı gerekli).
+
+import dynamic from "next/dynamic";
+
+export const ListeCekmecesiLazy = dynamic(
+ () => import("./liste-cekmecesi").then((m) => m.ListeCekmecesi),
+ { ssr: false },
+);
diff --git a/src/components/scroll-flow.tsx b/src/components/scroll-flow.tsx
index e878cf9..7ced048 100644
--- a/src/components/scroll-flow.tsx
+++ b/src/components/scroll-flow.tsx
@@ -58,13 +58,18 @@ export function ScrollFlow({
return `skewY(${v}deg)`;
});
- if (reduceMotion) {
- return {children}
;
- }
-
+ // Hareket azaltılmışsa da AYNI ağaç render edilir (server/client farklı
+ // ağaç hydration uyumsuzluğu yaratıyordu); efektler koşullu kapatılır:
+ // Lenis tekerlek yumuşatması kapalı, skew transform'u hiç bağlanmaz.
return (
-
-
+
+
{children}
diff --git a/src/components/sihirbaz-adimlar-lazy.tsx b/src/components/sihirbaz-adimlar-lazy.tsx
new file mode 100644
index 0000000..8a310ee
--- /dev/null
+++ b/src/components/sihirbaz-adimlar-lazy.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+// SihirbazAdimlar, turkey-map-react'in ~235 KB'lık il geometrisini taşır.
+// Modal kapalıyken bu yükün ilk sayfa bundle'ına girmemesi için tek noktadan
+// dynamic import: hem hero-form hem sonuc/sihirbaz-modal burayı kullanır.
+
+import dynamic from "next/dynamic";
+
+export const SihirbazAdimlarLazy = dynamic(
+ () =>
+ import("@/components/sihirbaz-adimlar").then((m) => m.SihirbazAdimlar),
+ {
+ ssr: false,
+ loading: () => (
+
+ Sihirbaz hazırlanıyor…
+
+ ),
+ },
+);
+
+/** Kullanıcı forma dokunduğu anda chunk'ı arka planda indir. */
+export function preloadSihirbazAdimlar() {
+ void import("@/components/sihirbaz-adimlar");
+}
diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx
index 620b380..a31869c 100644
--- a/src/components/site-header.tsx
+++ b/src/components/site-header.tsx
@@ -1,6 +1,7 @@
+import { Suspense } from "react";
import Image from "next/image";
import Link from "next/link";
-import { UserNav } from "@/components/user-nav";
+import { UserNav, UserNavFallback } from "@/components/user-nav";
const navLinks = [
{ href: "/#nasil-calisir", label: "Nasıl çalışır?" },
@@ -42,7 +43,10 @@ export function SiteHeader() {
))}
-
+ {/* UserNav session+DB bekler; Suspense dışına taşarsa tüm kabuk bloklanır */}
+ }>
+
+
);
diff --git a/src/components/ui/code-block.tsx b/src/components/ui/code-block.tsx
index 21e3365..1b529cb 100644
--- a/src/components/ui/code-block.tsx
+++ b/src/components/ui/code-block.tsx
@@ -2,7 +2,6 @@
import { cn } from "@/lib/utils"
import React, { useEffect, useState } from "react"
-import { codeToHtml } from "shiki"
export type CodeBlockProps = {
children?: React.ReactNode
@@ -47,6 +46,9 @@ function CodeBlockCode({
return
}
+ // Shiki ~380 KB: yalnızca gerçekten bir kod bloğu çizilirken indir;
+ // o ana dek aşağıdaki düz fallback'i görünür.
+ const { codeToHtml } = await import("shiki")
const html = await codeToHtml(code, { lang: language, theme })
setHighlightedHtml(html)
}
diff --git a/src/components/use-pixel-heat.ts b/src/components/use-pixel-heat.ts
index 781405a..ee020d8 100644
--- a/src/components/use-pixel-heat.ts
+++ b/src/components/use-pixel-heat.ts
@@ -8,6 +8,11 @@
* SVG katmanları çoğunlukla pointer-events-none olduğundan imleç window
* üzerinden izlenir ve viewBox koordinatına çevrilir. Beklenen veri
* öznitelikleri: data-px, data-cx, data-cy, data-op (taban opaklık).
+ *
+ * Landing'de 16 örnek yaşar: her örneğin kendi window dinleyicisi yerine
+ * modül seviyesinde TEK pointermove dinleyicisi + rAF birleştirmesi kullanılır;
+ * kare başına en fazla bir tarama yapılır, imleçten uzak örnekler hiç
+ * piksel iterasyonuna girmez.
*/
import { useEffect, type RefObject } from "react";
@@ -26,6 +31,99 @@ type HeatTarget = {
baseOpacity: number;
};
+type HeatInstance = {
+ svg: SVGSVGElement;
+ viewW: number;
+ radius: number;
+ baseColor: string;
+ pixels: HeatTarget[];
+ hot: Set;
+ controls: Map;
+};
+
+const instances = new Set();
+let sonOlay: PointerEvent | null = null;
+let rafId = 0;
+
+function calistir(
+ inst: HeatInstance,
+ el: SVGRectElement,
+ to: { fill: string; fillOpacity: number },
+ duration: number,
+) {
+ inst.controls.get(el)?.stop();
+ inst.controls.set(el, animate(el, to, { duration, ease: "easeOut" }));
+}
+
+function tara() {
+ rafId = 0;
+ const e = sonOlay;
+ if (!e) return;
+ for (const inst of instances) {
+ const rect = inst.svg.getBoundingClientRect();
+ if (rect.width === 0) continue;
+ const scale = rect.width / inst.viewW;
+ const pad = inst.radius * scale;
+ // İmleç fırça menzilinin tamamen dışındaysa ve sıcak piksel yoksa
+ // bu örnek için hiç hesap yapma
+ if (
+ inst.hot.size === 0 &&
+ (e.clientX < rect.left - pad ||
+ e.clientX > rect.right + pad ||
+ e.clientY < rect.top - pad ||
+ e.clientY > rect.bottom + pad)
+ ) {
+ continue;
+ }
+ const x = (e.clientX - rect.left) / scale;
+ const y = (e.clientY - rect.top) / scale;
+ for (const p of inst.pixels) {
+ const d2 = (p.cx - x) ** 2 + (p.cy - y) ** 2;
+ const isHot = d2 <= inst.radius * inst.radius;
+ if (isHot && !inst.hot.has(p.el)) {
+ inst.hot.add(p.el);
+ calistir(
+ inst,
+ p.el,
+ { fill: HOT_COLOR, fillOpacity: HOT_OPACITY },
+ HEAT_DURATION,
+ );
+ } else if (!isHot && inst.hot.has(p.el)) {
+ inst.hot.delete(p.el);
+ calistir(
+ inst,
+ p.el,
+ { fill: inst.baseColor, fillOpacity: p.baseOpacity },
+ COOL_DURATION,
+ );
+ }
+ }
+ }
+}
+
+function onMove(e: PointerEvent) {
+ sonOlay = e;
+ if (!rafId) rafId = requestAnimationFrame(tara);
+}
+
+function kaydol(inst: HeatInstance) {
+ if (instances.size === 0) {
+ window.addEventListener("pointermove", onMove, { passive: true });
+ }
+ instances.add(inst);
+}
+
+function ayril(inst: HeatInstance) {
+ instances.delete(inst);
+ inst.controls.forEach((c) => c.stop());
+ if (instances.size === 0) {
+ window.removeEventListener("pointermove", onMove);
+ if (rafId) cancelAnimationFrame(rafId);
+ rafId = 0;
+ sonOlay = null;
+ }
+}
+
export function usePixelHeat(
svgRef: RefObject,
{
@@ -42,7 +140,6 @@ export function usePixelHeat(
const viewW = svg.viewBox.baseVal.width;
if (!viewW) return;
- const baseColor = getComputedStyle(svg).color;
const pixels: HeatTarget[] = Array.from(
svg.querySelectorAll("[data-px]"),
).map((el) => ({
@@ -53,61 +150,16 @@ export function usePixelHeat(
}));
if (pixels.length === 0) return;
- const hot = new Set();
- const controls = new Map();
-
- const run = (
- el: SVGRectElement,
- to: { fill: string; fillOpacity: number },
- duration: number,
- ) => {
- controls.get(el)?.stop();
- controls.set(el, animate(el, to, { duration, ease: "easeOut" }));
- };
-
- const onMove = (e: PointerEvent) => {
- const rect = svg.getBoundingClientRect();
- if (rect.width === 0) return;
- const scale = rect.width / viewW;
- const pad = radius * scale;
- // İmleç fırça menzilinin tamamen dışındaysa ve sıcak piksel yoksa
- // hiç hesap yapma (sayfada birden çok örnek dinlediği için önemli)
- if (
- hot.size === 0 &&
- (e.clientX < rect.left - pad ||
- e.clientX > rect.right + pad ||
- e.clientY < rect.top - pad ||
- e.clientY > rect.bottom + pad)
- ) {
- return;
- }
- const x = (e.clientX - rect.left) / scale;
- const y = (e.clientY - rect.top) / scale;
- for (const p of pixels) {
- const d2 = (p.cx - x) ** 2 + (p.cy - y) ** 2;
- const isHot = d2 <= radius * radius;
- if (isHot && !hot.has(p.el)) {
- hot.add(p.el);
- run(
- p.el,
- { fill: HOT_COLOR, fillOpacity: HOT_OPACITY },
- HEAT_DURATION,
- );
- } else if (!isHot && hot.has(p.el)) {
- hot.delete(p.el);
- run(
- p.el,
- { fill: baseColor, fillOpacity: p.baseOpacity },
- COOL_DURATION,
- );
- }
- }
- };
-
- window.addEventListener("pointermove", onMove, { passive: true });
- return () => {
- window.removeEventListener("pointermove", onMove);
- controls.forEach((c) => c.stop());
+ const inst: HeatInstance = {
+ svg,
+ viewW,
+ radius,
+ baseColor: getComputedStyle(svg).color,
+ pixels,
+ hot: new Set(),
+ controls: new Map(),
};
+ kaydol(inst);
+ return () => ayril(inst);
}, [svgRef, radius]);
}
diff --git a/src/components/user-nav.tsx b/src/components/user-nav.tsx
index 78f9bd9..f5c55f6 100644
--- a/src/components/user-nav.tsx
+++ b/src/components/user-nav.tsx
@@ -5,6 +5,18 @@ import { Button } from "@/components/ui/button";
import { ListemButonu } from "@/components/manuel-liste/listem-butonu";
import { SignOutButton } from "./sign-out-button";
+/**
+ * Suspense fallback'i: girişsiz görünümle aynı yükseklik/genişlikte sessiz
+ * bir pill — session sorgusu akarken header zıplamasın (CLS yok).
+ */
+export function UserNavFallback() {
+ return (
+
+ );
+}
+
export async function UserNav() {
const u = await getCurrentUser();
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 6681449..57d8233 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -37,6 +37,12 @@ export const auth = betterAuth({
verification: schema.verification,
},
}),
+ session: {
+ // Session'ı imzalı cookie'de önbelle: her istekte session tablosu
+ // sorgusu yapılmaz. Kredi/paket tazeliği getCurrentUser()'daki ayrı
+ // user sorgusuyla korunur (src/lib/session.ts).
+ cookieCache: { enabled: true, maxAge: 5 * 60 },
+ },
user: {
additionalFields: {
creditBalance: { type: "number", defaultValue: 0, input: false },