Enhance error handling and user experience in AI-related features
Some checks failed
Deploy / deploy (push) Failing after 38m47s

- Introduced a mechanism to save partially generated responses in case of errors, ensuring users do not lose their input.
- Updated the RevizyonKutusu component to utilize a persistent request ID for retrying failed requests without double charging credits.
- Improved the Sayfalama component to support client-side pagination without changing the URL, enhancing user navigation.
- Added a timeout for AI generation requests to ensure credits are refunded in case of prolonged processing.
- Refactored various components for better clarity and responsiveness, including updates to the BolumProgramListesi and ListePaneli components.
This commit is contained in:
bilalgursen
2026-08-03 14:12:06 +03:00
parent c2aba25814
commit a5c2bcbc4d
13 changed files with 384 additions and 180 deletions

View File

@@ -1,10 +1,11 @@
"use client";
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import Link from "next/link";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { Program } from "@/lib/db";
import { trBaslikDuzeni } from "@/lib/slug";
import { Sayfalama, SAYFA_BOYU } from "@/components/sayfalama";
import {
ProgramSiraGecmisi,
ProgramTabanTrendi,
@@ -82,12 +83,22 @@ function SiralamaDugmesi({
export function BolumProgramListesi({
programlar,
bazAd,
tabanYol,
sayfa,
}: {
/** Bölümün TÜM programları — sıralama liste geneline uygulanır. */
programlar: BolumProgrami[];
bazAd: string;
tabanYol: string;
sayfa: number;
}) {
const [aktifAlan, setAktifAlan] = useState<SiralamaAlani | null>(null);
const [yon, setYon] = useState<SiralamaYonu>("artan");
// Sıralama aktifken URL değişmeden istemci tarafında gezilen sayfa
const [istemciSayfa, setIstemciSayfa] = useState(1);
const listeRef = useRef<HTMLDivElement>(null);
const toplamSayfa = Math.max(1, Math.ceil(programlar.length / SAYFA_BOYU));
const siraliProgramlar = useMemo(() => {
if (!aktifAlan) return programlar;
@@ -107,7 +118,18 @@ export function BolumProgramListesi({
.map(({ program }) => program);
}, [aktifAlan, programlar, yon]);
const gosterilenSayfa = aktifAlan ? istemciSayfa : sayfa;
const gosterilenProgramlar = useMemo(
() =>
siraliProgramlar.slice(
(gosterilenSayfa - 1) * SAYFA_BOYU,
gosterilenSayfa * SAYFA_BOYU,
),
[siraliProgramlar, gosterilenSayfa],
);
function sirala(alan: SiralamaAlani) {
setIstemciSayfa(1);
if (aktifAlan === alan) {
setYon((mevcut) => (mevcut === "artan" ? "azalan" : "artan"));
return;
@@ -116,124 +138,140 @@ export function BolumProgramListesi({
setYon("artan");
}
return (
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead>Üniversite</TableHead>
<TableHead className="hidden text-right md:table-cell">
Son 5 yıl
</TableHead>
<TableHead className="hidden text-right lg:table-cell">
<SiralamaDugmesi
alan="puan2025"
aktifAlan={aktifAlan}
yon={yon}
onSirala={sirala}
/>
</TableHead>
<TableHead className="hidden text-right sm:table-cell">
<SiralamaDugmesi
alan="kontenjan2025"
aktifAlan={aktifAlan}
yon={yon}
onSirala={sirala}
/>
</TableHead>
<TableHead className="text-right">
<SiralamaDugmesi
alan="sira2025"
aktifAlan={aktifAlan}
yon={yon}
onSirala={sirala}
/>
</TableHead>
<TableHead className="w-14 text-right">
<span className="sr-only">Listeye ekle</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{siraliProgramlar.map((p) => {
const uniAd = trBaslikDuzeni(p.universite);
const varyant = p.isim.trim() !== bazAd ? p.isim.trim() : null;
const devlet = p.unitur === "DEVLET";
function istemciSayfaDegistir(n: number) {
setIstemciSayfa(n);
listeRef.current?.scrollIntoView({ block: "start" });
}
return (
<TableRow key={p.id}>
<TableCell className="max-w-0 w-full">
<span className="flex min-w-0 items-center gap-2.5">
{p.universiteSlug ? (
<UniLogo slug={p.universiteSlug} ad={uniAd} boy="sm" />
) : null}
<span className="min-w-0 flex-1">
return (
<>
<div
ref={listeRef}
className="scroll-mt-24 overflow-hidden rounded-2xl border border-slate-200 bg-white"
>
<Table>
<TableHeader>
<TableRow>
<TableHead>Üniversite</TableHead>
<TableHead className="hidden text-right md:table-cell">
Son 5 yıl
</TableHead>
<TableHead className="hidden text-right lg:table-cell">
<SiralamaDugmesi
alan="puan2025"
aktifAlan={aktifAlan}
yon={yon}
onSirala={sirala}
/>
</TableHead>
<TableHead className="hidden text-right sm:table-cell">
<SiralamaDugmesi
alan="kontenjan2025"
aktifAlan={aktifAlan}
yon={yon}
onSirala={sirala}
/>
</TableHead>
<TableHead className="text-right">
<SiralamaDugmesi
alan="sira2025"
aktifAlan={aktifAlan}
yon={yon}
onSirala={sirala}
/>
</TableHead>
<TableHead className="w-14 text-right">
<span className="sr-only">Listeye ekle</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{gosterilenProgramlar.map((p) => {
const uniAd = trBaslikDuzeni(p.universite);
const varyant = p.isim.trim() !== bazAd ? p.isim.trim() : null;
const devlet = p.unitur === "DEVLET";
return (
<TableRow key={p.id}>
<TableCell className="max-w-0 w-full">
<span className="flex min-w-0 items-center gap-2.5">
{p.universiteSlug ? (
<Link
href={`/universite/${p.universiteSlug}`}
className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{uniAd}
</Link>
) : (
<span className="block truncate text-sm font-semibold">
{uniAd}
</span>
)}
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-slate-500">
{p.unitur ? (
<span
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
devlet
? "bg-slate-100 text-slate-600"
: "bg-violet-50 text-violet-600"
}`}
<UniLogo slug={p.universiteSlug} ad={uniAd} boy="sm" />
) : null}
<span className="min-w-0 flex-1">
{p.universiteSlug ? (
<Link
href={`/universite/${p.universiteSlug}`}
className="block truncate text-sm font-semibold hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{devlet ? "Devlet" : trBaslikDuzeni(p.unitur)}
{uniAd}
</Link>
) : (
<span className="block truncate text-sm font-semibold">
{uniAd}
</span>
) : null}
<span className="truncate">
{p.il ? trBaslikDuzeni(p.il) : null}
{varyant ? `${p.il ? " · " : ""}${varyant}` : ""}
)}
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-slate-500">
{p.unitur ? (
<span
className={`shrink-0 rounded px-1 py-px text-[10px] font-medium leading-4 ${
devlet
? "bg-slate-100 text-slate-600"
: "bg-violet-50 text-violet-600"
}`}
>
{devlet ? "Devlet" : trBaslikDuzeni(p.unitur)}
</span>
) : null}
<span className="truncate">
{p.il ? trBaslikDuzeni(p.il) : null}
{varyant ? `${p.il ? " · " : ""}${varyant}` : ""}
</span>
{p.sure ? (
<span className="shrink-0 text-slate-400">
· {p.sure} yıl
</span>
) : null}
</span>
{p.sure ? (
<span className="shrink-0 text-slate-400">
· {p.sure} yıl
</span>
) : null}
</span>
</span>
</span>
</TableCell>
<TableCell className="hidden text-right text-xs md:table-cell">
<ProgramSiraGecmisi program={p} />
</TableCell>
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 lg:table-cell">
{p.puan2025 != null
? p.puan2025.toFixed(2).replace(".", ",")
: "—"}
</TableCell>
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell">
{p.kontenjan2025 != null ? `${sayi(p.kontenjan2025)} kişi` : "—"}
</TableCell>
<TableCell className="text-right text-xs tabular-nums text-slate-500">
<span className="inline-flex items-center gap-1">
<ProgramTabanTrendi program={p} />
{p.sira2025 != null ? `~${sayi(p.sira2025)}.` : "—"}
</span>
</TableCell>
<TableCell className="pr-3 text-right">
<ProgramEkleButonu
program={p}
kaynak="bolum"
className="size-8 min-h-8 min-w-8"
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</TableCell>
<TableCell className="hidden text-right text-xs md:table-cell">
<ProgramSiraGecmisi program={p} />
</TableCell>
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 lg:table-cell">
{p.puan2025 != null
? p.puan2025.toFixed(2).replace(".", ",")
: "—"}
</TableCell>
<TableCell className="hidden text-right text-xs tabular-nums text-slate-500 sm:table-cell">
{p.kontenjan2025 != null ? `${sayi(p.kontenjan2025)} kişi` : "—"}
</TableCell>
<TableCell className="text-right text-xs tabular-nums text-slate-500">
<span className="inline-flex items-center gap-1">
<ProgramTabanTrendi program={p} />
{p.sira2025 != null ? `~${sayi(p.sira2025)}.` : "—"}
</span>
</TableCell>
<TableCell className="pr-3 text-right">
<ProgramEkleButonu
program={p}
kaynak="bolum"
className="size-8 min-h-8 min-w-8"
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
<Sayfalama
tabanYol={tabanYol}
sayfa={gosterilenSayfa}
toplamSayfa={toplamSayfa}
onSayfa={aktifAlan ? istemciSayfaDegistir : undefined}
/>
</>
);
}

View File

@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { ChevronLeft, ChevronRight } from "lucide-react";
@@ -8,23 +9,56 @@ export const SAYFA_BOYU = 50;
* Statik path tabanlı sayfalama navigasyonu. 1. sayfa taban URL'in
* kendisidir (/bolum/x), sonrakiler /bolum/x/sayfa/2 ... — hepsi gerçek
* <Link>, Google her sayfayı ayrı statik URL olarak tarar.
*
* `onSayfa` verilirse Link yerine aynı görünümde butonlar render edilir —
* istemci tarafında sıralanmış listelerin sayfalaması URL değiştirmeden
* bu geri çağrıyla ilerler.
*/
export function Sayfalama({
tabanYol,
sayfa,
toplamSayfa,
sorgu,
onSayfa,
}: {
tabanYol: string;
sayfa: number;
toplamSayfa: number;
sorgu?: string;
onSayfa?: (n: number) => void;
}) {
if (toplamSayfa <= 1) return null;
const yol = (n: number) => {
const path = n === 1 ? tabanYol : `${tabanYol}/sayfa/${n}`;
return sorgu ? `${path}?${sorgu}` : path;
};
const gecis = (
n: number,
className: string,
icerik: ReactNode,
ozellikler?: { rel?: string; aktif?: boolean },
) =>
onSayfa ? (
<button
key={n}
type="button"
onClick={() => onSayfa(n)}
aria-current={ozellikler?.aktif ? "page" : undefined}
className={`${className} cursor-pointer`}
>
{icerik}
</button>
) : (
<Link
key={n}
href={yol(n)}
rel={ozellikler?.rel}
aria-current={ozellikler?.aktif ? "page" : undefined}
className={className}
>
{icerik}
</Link>
);
// 7'den çok sayfada pencere: 1 … (c-1) c (c+1) … son
const numaralar: (number | "...")[] = [];
@@ -49,16 +83,17 @@ export function Sayfalama({
aria-label="Sayfalama"
className="mt-6 flex flex-wrap items-center justify-center gap-1.5"
>
{sayfa > 1 ? (
<Link
href={yol(sayfa - 1)}
rel="prev"
className="inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 hover:border-primary/40 hover:text-primary"
>
<ChevronLeft className="size-4" aria-hidden />
Önceki
</Link>
) : null}
{sayfa > 1
? gecis(
sayfa - 1,
"inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
<>
<ChevronLeft className="size-4" aria-hidden />
Önceki
</>,
{ rel: "prev" },
)
: null}
{numaralar.map((n, i) =>
n === "..." ? (
<span
@@ -69,30 +104,27 @@ export function Sayfalama({
…
</span>
) : (
<Link
key={n}
href={yol(n)}
aria-current={n === sayfa ? "page" : undefined}
className={
n === sayfa
? "inline-flex h-9 min-w-9 items-center justify-center rounded-lg bg-primary px-2 text-sm font-semibold text-primary-foreground"
: "inline-flex h-9 min-w-9 items-center justify-center rounded-lg border border-slate-200 bg-white px-2 text-sm text-slate-700 hover:border-primary/40 hover:text-primary"
}
>
{n}
</Link>
gecis(
n,
n === sayfa
? "inline-flex h-9 min-w-9 items-center justify-center rounded-lg bg-primary px-2 text-sm font-semibold text-primary-foreground"
: "inline-flex h-9 min-w-9 items-center justify-center rounded-lg border border-slate-200 bg-white px-2 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
n,
{ aktif: n === sayfa },
)
),
)}
{sayfa < toplamSayfa ? (
<Link
href={yol(sayfa + 1)}
rel="next"
className="inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 hover:border-primary/40 hover:text-primary"
>
Sonraki
<ChevronRight className="size-4" aria-hidden />
</Link>
) : null}
{sayfa < toplamSayfa
? gecis(
sayfa + 1,
"inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 hover:border-primary/40 hover:text-primary",
<>
Sonraki
<ChevronRight className="size-4" aria-hidden />
</>,
{ rel: "next" },
)
: null}
</nav>
);
}