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
All checks were successful
Deploy / deploy (push) Successful in 2m30s
This commit is contained in:
BIN
data/app.db
BIN
data/app.db
Binary file not shown.
BIN
data/yokatlas.db
BIN
data/yokatlas.db
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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() {
|
||||
|
||||
36
scripts/db-index.ts
Normal file
36
scripts/db-index.ts
Normal file
@@ -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();
|
||||
@@ -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 = ?
|
||||
|
||||
@@ -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. */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
19
src/app/odeme/sonuc/loading.tsx
Normal file
19
src/app/odeme/sonuc/loading.tsx
Normal 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
23
src/app/paket/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
21
src/app/rapor/yazdir/loading.tsx
Normal file
21
src/app/rapor/yazdir/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
12
src/components/manuel-liste/liste-cekmecesi-lazy.tsx
Normal file
12
src/components/manuel-liste/liste-cekmecesi-lazy.tsx
Normal 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 },
|
||||
);
|
||||
@@ -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>
|
||||
|
||||
28
src/components/sihirbaz-adimlar-lazy.tsx
Normal file
28
src/components/sihirbaz-adimlar-lazy.tsx
Normal 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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user