Files
kolaytercih/src/components/katalog-arama.tsx
bilalgursen 716f7bb3fb
All checks were successful
Deploy / deploy (push) Successful in 16m29s
Add "detay" script to package.json and enhance ListemSekmeleri component functionality
- Introduced a new "detay" script in package.json for additional processing.
- Refactored ListemSekmeleri component to improve tab functionality and state management.
- Updated UI elements for better user interaction and clarity, including renaming "Seçtiklerim" to "Kendi Listem" for consistency across the application.
- Made adjustments to related components for improved integration and user experience.
2026-08-01 21:58:28 +03:00

600 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { RehberKapak } from "@/components/rehber-kapak";
import { UniLogo } from "@/components/uni-logo";
import { aramaNormalize, eslesmePuani } from "@/lib/arama";
import type { RehberOzet } from "@/lib/rehber";
import { PUAN_TURU_ETIKET } from "@/lib/sihirbaz";
import {
profilDuzenlemeyiAc,
useTercihProfili,
} from "@/components/manuel-liste/profil-kapisi-store";
import {
ArrowRight,
BookOpenText,
Building2,
GraduationCap,
Search,
SquarePen,
Trophy,
X,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41";
type BolumSonucu = {
href: string;
ad: string;
programSayisi: number;
enIyiSira: number | null;
seviye: string;
};
type UniversiteSonucu = {
href: string;
ad: string;
il: string | null;
tur: string | null;
programSayisi: number;
fakulteSayisi: number;
};
type AramaSonuclari = {
bolumler: BolumSonucu[];
universiteler: UniversiteSonucu[];
};
const BOS_SONUCLAR: AramaSonuclari = { bolumler: [], universiteler: [] };
const REHBER_SONUC_SINIRI = 4;
const sayi = (deger: number) => deger.toLocaleString("tr-TR");
export function KatalogArama({ rehberler }: { rehberler: RehberOzet[] }) {
const profil = useTercihProfili();
const [acik, setAcik] = useState(false);
const [sorgu, setSorgu] = useState("");
const [sonuclar, setSonuclar] = useState<AramaSonuclari>(BOS_SONUCLAR);
const [yukleniyor, setYukleniyor] = useState(false);
const [macMi, setMacMi] = useState(true);
const inputRef = useRef<HTMLInputElement>(null);
const kaydirmaRef = useRef<HTMLDivElement>(null);
const [kenarBlur, setKenarBlur] = useState({ ust: false, alt: false });
const temizSorgu = sorgu.trim();
const aramaAktif = temizSorgu.length >= 2;
const kenarBlurGuncelle = useCallback(() => {
const alan = kaydirmaRef.current;
if (!alan) return;
const ust = alan.scrollTop > 4;
const alt = alan.scrollTop + alan.clientHeight < alan.scrollHeight - 4;
setKenarBlur((onceki) =>
onceki.ust === ust && onceki.alt === alt ? onceki : { ust, alt },
);
}, []);
// Rehber özetleri build sırasında server'dan prop olarak gelir (~10 yazı);
// arama client'ta yapılır — prod imajında content/ dizini bulunmaz.
const rehberSonuclari = useMemo(() => {
if (!aramaAktif) return [];
const normalSorgu = aramaNormalize(temizSorgu).slice(0, 80);
return rehberler
.filter((yazi) =>
aramaNormalize(`${yazi.baslik} ${yazi.aciklama}`).includes(normalSorgu),
)
.sort(
(a, b) =>
eslesmePuani(a.baslik, normalSorgu) -
eslesmePuani(b.baslik, normalSorgu),
)
.slice(0, REHBER_SONUC_SINIRI);
}, [aramaAktif, temizSorgu, rehberler]);
const sonucVar =
sonuclar.bolumler.length > 0 ||
sonuclar.universiteler.length > 0 ||
rehberSonuclari.length > 0;
useEffect(() => {
const platform =
(navigator as { userAgentData?: { platform?: string } }).userAgentData
?.platform ??
navigator.platform ??
"";
setMacMi(/mac|iphone|ipad/i.test(platform));
}, []);
useEffect(() => {
function kisayolDinle(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
modalDegisti(!acik);
}
}
window.addEventListener("keydown", kisayolDinle);
return () => window.removeEventListener("keydown", kisayolDinle);
}, [acik]);
useEffect(() => {
if (!acik || !aramaAktif) return;
const controller = new AbortController();
const zamanlayici = window.setTimeout(async () => {
setYukleniyor(true);
try {
const response = await fetch(
`/api/katalog-ara?q=${encodeURIComponent(temizSorgu)}`,
{ signal: controller.signal },
);
if (!response.ok) throw new Error("Arama başarısız");
setSonuclar((await response.json()) as AramaSonuclari);
} catch (hata) {
if (!(hata instanceof DOMException && hata.name === "AbortError")) {
setSonuclar(BOS_SONUCLAR);
}
} finally {
if (!controller.signal.aborted) setYukleniyor(false);
}
}, 160);
return () => {
window.clearTimeout(zamanlayici);
controller.abort();
};
}, [acik, aramaAktif, temizSorgu]);
// Sonuçlar, logolar ve kapak görselleri yüklendikçe scrollHeight değişir;
// kenar blur görünürlüğünü scroll olayına ek olarak boyut değişiminde de tazele.
useEffect(() => {
if (!acik) return;
const alan = kaydirmaRef.current;
if (!alan) return;
kenarBlurGuncelle();
const gozlemci = new ResizeObserver(kenarBlurGuncelle);
gozlemci.observe(alan);
for (const cocuk of alan.children) gozlemci.observe(cocuk);
return () => gozlemci.disconnect();
}, [acik, aramaAktif, yukleniyor, sonucVar, kenarBlurGuncelle]);
function modalDegisti(yeniAcik: boolean) {
setAcik(yeniAcik);
if (!yeniAcik) {
setSorgu("");
setSonuclar(BOS_SONUCLAR);
}
}
function kapat() {
setAcik(false);
}
return (
<>
{/* lg altında logo + sağ küme ile çakışır (absolute); o aralıkta gizli */}
<nav className="absolute left-1/2 hidden -translate-x-1/2 items-center gap-1 rounded-full border border-slate-200 bg-white p-1.5 text-base font-medium text-slate-600 lg:flex">
{profil ? (
<button
type="button"
onClick={profilDuzenlemeyiAc}
aria-label={`Sıralaman ${profil.sira.toLocaleString("tr-TR")} (${PUAN_TURU_ETIKET[profil.tur] ?? profil.tur}) — tercihlerini düzenle`}
className="group/profil flex cursor-pointer items-center gap-2.5 rounded-full py-1.5 pl-4 pr-1.5 transition-colors duration-200 hover:bg-slate-100"
>
<Trophy className="size-4 text-orange-500" aria-hidden />
<span className="flex items-baseline gap-1.5">
<span className="font-semibold tabular-nums tracking-tight text-slate-900">
{profil.sira.toLocaleString("tr-TR")}
</span>
<span className="text-xs font-medium text-slate-400">
{PUAN_TURU_ETIKET[profil.tur] ?? profil.tur}
</span>
</span>
<span className="flex items-center gap-1 rounded-full bg-slate-100 px-2.5 py-1.5 text-xs font-medium text-slate-500 transition-colors duration-200 group-hover/profil:bg-white group-hover/profil:text-slate-900">
<SquarePen className="size-3" aria-hidden />
Düzenle
</span>
</button>
) : (
<>
<Link
href="/#nasil-calisir"
className="rounded-full px-5 py-2 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900"
>
Nasıl çalışır?
</Link>
<Link
href="/#karsilastirma"
className="rounded-full px-5 py-2 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-900"
>
Karşılaştır
</Link>
</>
)}
<button
type="button"
onClick={() => setAcik(true)}
className="flex cursor-pointer items-center gap-2 rounded-full bg-slate-900 px-5 py-2 text-white transition-[background-color,transform] duration-150 hover:bg-slate-700 active:scale-[0.97]"
>
<Search className="size-4" aria-hidden />
Ara
<KbdGroup aria-hidden className="ml-1">
<Kbd className="bg-white/15 text-white/85">
{macMi ? "⌘" : "Ctrl"}
</Kbd>
<Kbd className="bg-white/15 text-white/85">K</Kbd>
</KbdGroup>
</button>
</nav>
<button
type="button"
onClick={() => setAcik(true)}
aria-label="Bölüm, üniversite veya rehber ara"
className="ml-auto flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 transition-[background-color,transform] duration-150 hover:bg-slate-100 active:scale-[0.96] sm:size-12 lg:hidden"
>
<Search className="size-4" aria-hidden />
</button>
<Dialog open={acik} onOpenChange={modalDegisti}>
<DialogContent
showCloseButton={false}
onOpenAutoFocus={(event) => {
event.preventDefault();
inputRef.current?.focus();
}}
className="max-h-[min(720px,calc(100dvh-2rem))] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden rounded-2xl bg-white p-0 duration-200 sm:max-w-2xl motion-reduce:data-open:zoom-in-100 motion-reduce:data-closed:zoom-out-100"
>
<DialogTitle className="sr-only">
Bölüm, üniversite ve rehber ara
</DialogTitle>
<DialogDescription className="sr-only">
YKS bölümlerini, üniversiteleri ve rehber yazılarını tek yerden
ara.
</DialogDescription>
<div className="flex items-center border-b border-slate-200 px-4 sm:px-5">
<Search
className="mr-3 size-5 shrink-0 text-slate-400"
aria-hidden
/>
<input
ref={inputRef}
type="text"
value={sorgu}
onChange={(event) => {
const yeniSorgu = event.target.value;
setSorgu(yeniSorgu);
setSonuclar(BOS_SONUCLAR);
setYukleniyor(yeniSorgu.trim().length >= 2);
}}
placeholder="Bölüm, üniversite veya rehber ara..."
aria-label="Bölüm, üniversite veya rehber ara"
autoComplete="off"
className="h-16 min-w-0 flex-1 bg-transparent text-base text-slate-950 outline-none placeholder:text-slate-400 sm:h-18 sm:text-lg"
/>
<button
type="button"
onClick={kapat}
aria-label="Aramayı kapat"
className="flex size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200 text-slate-500 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96]"
>
<X className="size-4" aria-hidden />
</button>
</div>
<div className="relative min-h-0">
<div
ref={kaydirmaRef}
onScroll={kenarBlurGuncelle}
className="h-full max-h-full min-h-72 overflow-y-auto overscroll-contain p-3 sm:p-4"
aria-live="polite"
>
{!aramaAktif ? (
<BaslangicIcerigi onSec={kapat} />
) : yukleniyor ? (
<AramaIskeleti />
) : sonucVar ? (
<div className="space-y-5">
{sonuclar.bolumler.length > 0 ? (
<SonucGrubu
baslik="Bölümler"
ikon={<GraduationCap className="size-4" aria-hidden />}
>
{sonuclar.bolumler.map((bolum) => (
<SonucLinki
key={bolum.href}
href={bolum.href}
baslik={bolum.ad}
meta={`${bolum.seviye} · ${sayi(bolum.programSayisi)} program`}
detay={
bolum.enIyiSira
? `En iyi sıra ${sayi(bolum.enIyiSira)}`
: "Sıralama verisi yok"
}
onSec={kapat}
/>
))}
</SonucGrubu>
) : null}
{sonuclar.universiteler.length > 0 ? (
<SonucGrubu
baslik="Üniversiteler"
ikon={<Building2 className="size-4" aria-hidden />}
>
{sonuclar.universiteler.map((universite) => (
<SonucLinki
key={universite.href}
href={universite.href}
gorsel={
<UniLogo
slug={universite.href.split("/").pop() ?? ""}
ad={universite.ad}
boy="sm"
/>
}
baslik={universite.ad}
meta={
[universite.il, universite.tur]
.filter(Boolean)
.join(" · ") || "Üniversite"
}
detay={`${sayi(universite.programSayisi)} program · ${sayi(universite.fakulteSayisi)} fakülte`}
onSec={kapat}
/>
))}
</SonucGrubu>
) : null}
{rehberSonuclari.length > 0 ? (
<SonucGrubu
baslik="Rehber yazıları"
ikon={<BookOpenText className="size-4" aria-hidden />}
>
<div className="grid gap-2 sm:grid-cols-2">
{rehberSonuclari.map((yazi) => (
<RehberSonucKarti
key={yazi.slug}
yazi={yazi}
onSec={kapat}
/>
))}
</div>
</SonucGrubu>
) : null}
</div>
) : (
<div className="flex min-h-64 flex-col items-center justify-center px-6 text-center">
<div className="flex size-12 items-center justify-center rounded-2xl bg-slate-100 text-slate-500">
<Search className="size-5" aria-hidden />
</div>
<p className="mt-4 font-medium text-slate-900">
&quot;{temizSorgu}&quot; için sonuç bulunamadı
</p>
<p className="mt-1 max-w-sm text-sm leading-6 text-slate-500">
Bölüm, üniversite veya rehber konusunu farklı yazarak tekrar
dene.
</p>
</div>
)}
</div>
<div
aria-hidden
className={`pointer-events-none absolute inset-x-0 top-0 h-14 transition-opacity duration-200 ${kenarBlur.ust ? "opacity-100" : "opacity-0"}`}
>
<ProgressiveBlur
position="top"
backgroundColor="#ffffff"
height="56px"
blurAmount="4px"
/>
</div>
<div
aria-hidden
className={`pointer-events-none absolute inset-x-0 bottom-0 h-14 transition-opacity duration-200 ${kenarBlur.alt ? "opacity-100" : "opacity-0"}`}
>
<ProgressiveBlur
position="bottom"
backgroundColor="#ffffff"
height="56px"
blurAmount="4px"
/>
</div>
</div>
<div className="flex items-center justify-between border-t border-slate-200 bg-slate-50/80 px-4 py-3 text-xs text-slate-500 sm:px-5">
<span>En az 2 harf yazarak ara</span>
<span className="hidden items-center gap-1.5 sm:flex">
<Kbd>Esc</Kbd> ile kapat
</span>
</div>
</DialogContent>
</Dialog>
</>
);
}
function BaslangicIcerigi({ onSec }: { onSec: () => void }) {
return (
<div>
<p className="px-2 pb-2 text-xs font-semibold uppercase tracking-wider text-slate-400">
Keşfet
</p>
<div className="grid gap-2 sm:grid-cols-3">
<KesfetLinki
href="/bolumler"
ikon={<GraduationCap className="size-5" aria-hidden />}
baslik="Tüm bölümler"
aciklama="Lisans ve önlisans"
onSec={onSec}
/>
<KesfetLinki
href="/universiteler"
ikon={<Building2 className="size-5" aria-hidden />}
baslik="Üniversiteler"
aciklama="Devlet ve vakıf"
onSec={onSec}
/>
<KesfetLinki
href="/rehber"
ikon={<BookOpenText className="size-5" aria-hidden />}
baslik="Tercih rehberi"
aciklama="Sorularına yanıtlar"
onSec={onSec}
/>
</div>
<div className="mt-6 rounded-xl border border-orange-100 bg-orange-50/70 p-4">
<p className="text-sm font-medium text-slate-900">
Neyi merak ediyorsun?
</p>
<p className="mt-1 text-sm leading-6 text-slate-600">
Psikoloji veya Boğaziçi gibi bir ad yaz; eşleşen sayfaları ve
temel bilgilerini burada önizle.
</p>
</div>
</div>
);
}
function KesfetLinki({
href,
ikon,
baslik,
aciklama,
onSec,
}: {
href: string;
ikon: React.ReactNode;
baslik: string;
aciklama: string;
onSec: () => void;
}) {
return (
<Link
href={href}
onClick={onSec}
className="group rounded-xl border border-slate-200 bg-white p-4 transition-[border-color,background-color,transform] duration-150 hover:border-slate-300 hover:bg-slate-50 active:scale-[0.98]"
>
<span className="flex size-9 items-center justify-center rounded-lg bg-slate-100 text-slate-600 transition-colors group-hover:bg-white">
{ikon}
</span>
<span className="mt-3 block text-sm font-semibold text-slate-900">
{baslik}
</span>
<span className="mt-0.5 block text-xs text-slate-500">{aciklama}</span>
</Link>
);
}
function SonucGrubu({
baslik,
ikon,
children,
}: {
baslik: string;
ikon: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section>
<h3 className="mb-2 flex items-center gap-2 px-2 text-xs font-semibold uppercase tracking-wider text-slate-400">
{ikon}
{baslik}
</h3>
<div className="space-y-1">{children}</div>
</section>
);
}
function SonucLinki({
href,
baslik,
meta,
detay,
gorsel,
onSec,
}: {
href: string;
baslik: string;
meta: string;
detay: string;
gorsel?: React.ReactNode;
onSec: () => void;
}) {
return (
<Link
href={href}
onClick={onSec}
className="group flex items-center gap-3 rounded-xl px-3 py-3 transition-colors duration-150 hover:bg-slate-100"
>
{gorsel}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-slate-900">
{baslik}
</span>
<span className="mt-1 block truncate text-xs text-slate-500">
{meta}
</span>
</span>
<span className="hidden shrink-0 text-right text-xs text-slate-400 sm:block">
{detay}
</span>
<ArrowRight
className="size-4 shrink-0 text-slate-300 transition-[color,transform] duration-150 group-hover:translate-x-0.5 group-hover:text-slate-600"
aria-hidden
/>
</Link>
);
}
function RehberSonucKarti({
yazi,
onSec,
}: {
yazi: RehberOzet;
onSec: () => void;
}) {
return (
<Link
href={`/rehber/${yazi.slug}`}
onClick={onSec}
className="group overflow-hidden rounded-xl border border-slate-200 bg-white p-1.5 transition-[border-color,background-color,transform] duration-150 hover:border-slate-300 hover:bg-slate-50 active:scale-[0.99]"
>
<RehberKapak baslik={yazi.baslik} compact className="rounded-lg" />
<span className="block px-2 pb-1.5 pt-2.5">
<span className="line-clamp-2 text-xs leading-5 text-slate-600">
{yazi.aciklama}
</span>
<span className="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-primary">
Yazıyı oku
<ArrowRight
className="size-3.5 transition-transform duration-150 group-hover:translate-x-0.5"
aria-hidden
/>
</span>
</span>
</Link>
);
}
function AramaIskeleti() {
return (
<div className="space-y-2 px-2 pt-2" aria-label="Aranıyor">
{[0, 1, 2, 3].map((sira) => (
<div
key={sira}
className="flex h-16 animate-pulse items-center rounded-xl bg-slate-100 px-3 motion-reduce:animate-none"
>
<div className="h-4 w-2/5 rounded bg-slate-200" />
</div>
))}
</div>
);
}