Enhance application performance and user experience by enabling component caching in next.config.ts, optimizing database queries in various pages, and implementing lazy loading for heavy components. Updated styles for smoother transitions and improved visual clarity in multiple components, including globals.css and landing sections.
All checks were successful
Deploy / deploy (push) Successful in 2m30s

This commit is contained in:
bilalgursen
2026-08-01 14:00:22 +03:00
parent acb3b9d557
commit 569b9e5da2
29 changed files with 458 additions and 126 deletions

View File

@@ -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. */
}
}

View File

@@ -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}
</ScrollFlow>
{/* Manuel 24'lük liste çekmecesi + "+" uçuş katmanı (navbar'dan açılır) */}
<ListeCekmecesi />
<ListeCekmecesiLazy />
<Toaster position="top-center" />
{/* Dev süperadmin paneli — prod build'de hiç render edilmez */}
{process.env.NODE_ENV !== "production" ? <DevPanel /> : null}

View File

@@ -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({
<div id="sohbet" className="scroll-mt-24 lg:sticky lg:top-28">
{user.hasPaket ? (
<SohbetClient baslangicKredi={user.creditBalance} />
<SohbetClient
baslangicKredi={user.creditBalance}
initialMesajlar={sohbetGecmisi as Mesaj[]}
/>
) : (
<div className="flex flex-col gap-6">
<section className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
@@ -317,6 +334,7 @@ export default async function ListemPage({
tattırır (kalan kredi biterse composer paket CTA'sına döner). */}
<SohbetClient
baslangicKredi={user.creditBalance}
initialMesajlar={sohbetGecmisi as Mesaj[]}
className="h-[480px]"
/>
</div>

View File

@@ -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<Mesaj[]>([]);
const [mesajlar, setMesajlar] = useState<Mesaj[]>(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();

View File

@@ -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 (
<main
aria-live="polite"
className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-16"
>
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<Skeleton className="mx-auto size-12 rounded-full" />
<Skeleton className="mx-auto mt-5 h-7 w-2/3" />
<Skeleton className="mx-auto mt-3 h-5 w-full" />
<Skeleton className="mx-auto mt-2 h-5 w-3/4" />
<Skeleton className="mx-auto mt-6 h-12 w-full rounded-lg" />
</div>
</main>
);
}

23
src/app/paket/loading.tsx Normal file
View File

@@ -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 (
<main
aria-live="polite"
className="mx-auto w-full max-w-3xl flex-1 px-4 py-16"
>
<div className="flex flex-col items-center text-center">
<Skeleton className="h-6 w-56 rounded-full" />
<Skeleton className="mt-4 h-10 w-3/4 max-w-xl" />
<Skeleton className="mt-3 h-5 w-2/3 max-w-lg" />
<Skeleton className="mt-2 h-5 w-1/2 max-w-md" />
</div>
<div className="mt-10 grid gap-6 sm:grid-cols-[1fr_auto]">
<Skeleton className="h-80 w-full rounded-2xl" />
<Skeleton className="h-80 w-full rounded-2xl sm:w-64" />
</div>
</main>
);
}

View File

@@ -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";

View File

@@ -0,0 +1,21 @@
import { Skeleton } from "@/components/ui/skeleton";
// /rapor/yazdir rapor sorgusunu bekler — belge düzeninde iskelet.
export default function Loading() {
return (
<main
aria-live="polite"
className="mx-auto w-full max-w-3xl bg-white p-8"
>
<div className="border-b-2 border-slate-200 pb-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="mt-2 h-4 w-40" />
</div>
<div className="mt-6 space-y-3">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-lg" />
))}
</div>
</main>
);
}

View File

@@ -20,14 +20,16 @@ const DILIM_LABEL: Record<string, string> = {
};
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;

View File

@@ -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)

View File

@@ -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({
<DialogDescription className="sr-only">
Sıralamana uygun 24 tercihlik listeni oluştur.
</DialogDescription>
<SihirbazAdimlar
<SihirbazAdimlarLazy
facetler={facetler}
baslangicSecimler={baslangicSecimler}
sonButonEtiketi={sonButonEtiketi}

View File

@@ -5,11 +5,25 @@
// spring ile açılır; çıkış girişten hızlı. Reduced motion'da cross-fade.
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { MessageCircleMore, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { SohbetClient } from "@/app/listem/sohbet-client";
import { ViewportPortal } from "@/components/viewport-portal";
// Sohbet istemcisi (markdown zinciriyle birlikte) ağır; popup ilk açıldığında
// yüklensin — /sonuc ilk yükünü şişirmesin.
const SohbetClient = dynamic(
() => import("@/app/listem/sohbet-client").then((m) => m.SohbetClient),
{
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center rounded-2xl border border-slate-200 bg-white text-sm text-slate-500 shadow-2xl">
Sohbet yükleniyor
</div>
),
},
);
export function SohbetPopup({ kredi }: { kredi: number }) {
const [acik, setAcik] = useState(false);
const azMotion = useReducedMotion();

View File

@@ -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<string | null>(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ç.
</DialogDescription>
{facetler ? (
<SihirbazAdimlar
<SihirbazAdimlarLazy
key={`${sira}-${tur}`}
facetler={facetler}
sonButonEtiketi={

View File

@@ -1,12 +1,13 @@
"use client";
import type { ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* Landing bölümlerinin görünüme girerken yumuşakça belirmesi.
* Yalnızca transform/opacity animasyonu yapar; hareket azaltılmışsa
* kaydırma yerine sadece kısa bir belirme kalır.
* Motion yerine CSS transition + IntersectionObserver: SSR HTML'i görünür
* gelir (JS'siz/yavaş istemcide sayfa boş kalmaz), gizleme yalnızca
* `@media (scripting: enabled)` altında yaşar (bkz. globals.css .kt-reveal).
*/
export function Reveal({
children,
@@ -19,20 +20,38 @@ export function Reveal({
y?: number;
className?: string;
}) {
const reduceMotion = useReducedMotion();
const ref = useRef<HTMLDivElement>(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 (
<motion.div
className={className}
initial={{ opacity: 0, y: reduceMotion ? 0 : y }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "0px 0px -80px 0px" }}
transition={{
duration: reduceMotion ? 0.25 : 0.55,
delay,
ease: [0.21, 0.47, 0.32, 0.98],
}}
<div
ref={ref}
className={cn("kt-reveal", gorunur && "kt-reveal-in", className)}
style={{
transitionDelay: delay ? `${delay}s` : undefined,
...(y !== 28 ? { "--reveal-y": `${y}px` } : null),
} as React.CSSProperties}
>
{children}
</motion.div>
</div>
);
}

View File

@@ -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 },
);

View File

@@ -58,13 +58,18 @@ export function ScrollFlow({
return `skewY(${v}deg)`;
});
if (reduceMotion) {
return <div className={className}>{children}</div>;
}
// 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 (
<ReactLenis root options={{ lerp: 0.09, anchors: true }}>
<motion.div className={className} style={{ transform }}>
<ReactLenis
root
options={{ lerp: 0.09, anchors: true, smoothWheel: !reduceMotion }}
>
<motion.div
className={className}
style={reduceMotion ? undefined : { transform }}
>
{children}
</motion.div>
</ReactLenis>

View File

@@ -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: () => (
<div
aria-live="polite"
className="flex min-h-64 items-center justify-center text-sm text-slate-500"
>
Sihirbaz hazırlanıyor
</div>
),
},
);
/** Kullanıcı forma dokunduğu anda chunk'ı arka planda indir. */
export function preloadSihirbazAdimlar() {
void import("@/components/sihirbaz-adimlar");
}

View File

@@ -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() {
</Link>
))}
</nav>
<UserNav />
{/* UserNav session+DB bekler; Suspense dışına taşarsa tüm kabuk bloklanır */}
<Suspense fallback={<UserNavFallback />}>
<UserNav />
</Suspense>
</div>
</header>
);

View File

@@ -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 <pre> fallback'i görünür.
const { codeToHtml } = await import("shiki")
const html = await codeToHtml(code, { lang: language, theme })
setHighlightedHtml(html)
}

View File

@@ -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<SVGRectElement>;
controls: Map<SVGRectElement, AnimationPlaybackControls>;
};
const instances = new Set<HeatInstance>();
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<SVGSVGElement | null>,
{
@@ -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<SVGRectElement>("[data-px]"),
).map((el) => ({
@@ -53,61 +150,16 @@ export function usePixelHeat(
}));
if (pixels.length === 0) return;
const hot = new Set<SVGRectElement>();
const controls = new Map<SVGRectElement, AnimationPlaybackControls>();
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]);
}

View File

@@ -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 (
<div className="flex items-center gap-2" aria-hidden>
<div className="h-12 w-28 rounded-full bg-slate-900/10" />
</div>
);
}
export async function UserNav() {
const u = await getCurrentUser();

View File

@@ -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 },