Update Next.js configuration and package dependencies
All checks were successful
Deploy / deploy (push) Successful in 9m29s
All checks were successful
Deploy / deploy (push) Successful in 9m29s
- Modified redirects in next.config.ts to change the destination for "/sohbet" to "/listem". - Added new dependencies in package.json: marked, motion, react-markdown, remark-breaks, remark-gfm, shiki, and use-stick-to-bottom. - Updated pnpm-lock.yaml to reflect the new package versions and dependencies. - Removed obsolete stack files for Flutter, Nuxt, Nuxt.js, React Native, and Svelte from the UI/UX Pro Max skill data. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,182 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { cities } from "turkey-map-react/lib/data";
|
||||
|
||||
export type IlOzeti = {
|
||||
hayal: number;
|
||||
dengeli: number;
|
||||
garanti: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
/** DB'deki il adıyla (büyük harf Türkçe) program sayıları */
|
||||
iller: Record<string, IlOzeti>;
|
||||
sira: number;
|
||||
turKey: string;
|
||||
seciliIl: string | null;
|
||||
};
|
||||
|
||||
// Harita adını DB formatına çevirir: "Hakkâri" → "HAKKARİ"
|
||||
function normalizeIl(name: string): string {
|
||||
return name
|
||||
.toLocaleUpperCase("tr-TR")
|
||||
.replace(/Â/g, "A")
|
||||
.replace(/Î/g, "İ")
|
||||
.replace(/Û/g, "U");
|
||||
}
|
||||
|
||||
function toplam(o: IlOzeti): number {
|
||||
return o.hayal + o.dengeli + o.garanti;
|
||||
}
|
||||
|
||||
type Secim = {
|
||||
name: string;
|
||||
ozet: IlOzeti;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export function BolgeHaritasi({ iller, sira, turKey, seciliIl }: Props) {
|
||||
const [secim, setSecim] = useState<Secim | null>(null);
|
||||
const kapsayici = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
|
||||
function filtrele(ilKey: string) {
|
||||
const url =
|
||||
ilKey === seciliIl
|
||||
? `/sonuc?sira=${sira}&tur=${turKey}`
|
||||
: `/sonuc?sira=${sira}&tur=${turKey}&il=${encodeURIComponent(ilKey)}`;
|
||||
router.push(url, { scroll: false });
|
||||
}
|
||||
|
||||
const maxToplam = Math.max(1, ...Object.values(iller).map((o) => toplam(o)));
|
||||
|
||||
// Haritada karşılığı olmayan yerler (KKTC, yurtdışı kampüsler)
|
||||
const haritaIlleri = new Set(cities.map((c) => normalizeIl(c.name)));
|
||||
const haritaDisi = Object.entries(iller).filter(
|
||||
([il]) => !haritaIlleri.has(il)
|
||||
);
|
||||
|
||||
function sec(el: SVGPathElement, name: string, ozet: IlOzeti) {
|
||||
const kutu = kapsayici.current?.getBoundingClientRect();
|
||||
const r = el.getBoundingClientRect();
|
||||
if (!kutu) return;
|
||||
setSecim({
|
||||
name,
|
||||
ozet,
|
||||
x: r.x - kutu.x + r.width / 2,
|
||||
y: r.y - kutu.y,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={kapsayici} className="relative">
|
||||
<svg
|
||||
viewBox="0 0 1050 585"
|
||||
className="h-auto w-full"
|
||||
role="img"
|
||||
aria-label="Önerilen programların illere dağılımı haritası"
|
||||
onClick={(e) => {
|
||||
if ((e.target as Element).tagName !== "path") setSecim(null);
|
||||
}}
|
||||
>
|
||||
{/* Seçili il en son çizilir ki turuncu konturu komşu iller örtmesin */}
|
||||
{[...cities]
|
||||
.sort((a, b) =>
|
||||
(normalizeIl(a.name) === seciliIl ? 1 : 0) -
|
||||
(normalizeIl(b.name) === seciliIl ? 1 : 0)
|
||||
)
|
||||
.map((city) => {
|
||||
const key = normalizeIl(city.name);
|
||||
const ozet = iller[key];
|
||||
const yogunluk = ozet ? toplam(ozet) / maxToplam : 0;
|
||||
const secili = key === seciliIl;
|
||||
return (
|
||||
<path
|
||||
key={city.id}
|
||||
d={city.path}
|
||||
className={
|
||||
ozet
|
||||
? "cursor-pointer transition-opacity duration-200 hover:opacity-80"
|
||||
: "fill-slate-200 stroke-white"
|
||||
}
|
||||
style={
|
||||
ozet
|
||||
? {
|
||||
fill: `oklch(0.623 0.188 259.8 / ${0.3 + 0.7 * yogunluk})`,
|
||||
stroke: secili ? "oklch(0.705 0.213 47.6)" : "white",
|
||||
strokeWidth: secili ? 2.5 : 1,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
strokeWidth={1}
|
||||
onClick={
|
||||
ozet ? () => filtrele(key) : () => setSecim(null)
|
||||
}
|
||||
onMouseEnter={
|
||||
ozet
|
||||
? (e) => sec(e.currentTarget, city.name, ozet)
|
||||
: undefined
|
||||
}
|
||||
onMouseLeave={ozet ? () => setSecim(null) : undefined}
|
||||
>
|
||||
<title>
|
||||
{ozet
|
||||
? `${city.name}: ${toplam(ozet)} program — filtrelemek için tıkla`
|
||||
: city.name}
|
||||
</title>
|
||||
</path>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{secim ? (
|
||||
<div
|
||||
data-testid="harita-tooltip"
|
||||
className="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm shadow-md"
|
||||
style={{ left: secim.x, top: secim.y - 6 }}
|
||||
>
|
||||
<p className="font-heading font-semibold">{secim.name}</p>
|
||||
<p className="text-slate-600">
|
||||
{toplam(secim.ozet)} program
|
||||
<span className="block text-xs">
|
||||
{secim.ozet.hayal} hayal · {secim.ozet.dengeli} dengeli ·{" "}
|
||||
{secim.ozet.garanti} garanti
|
||||
</span>
|
||||
<span className="block text-xs font-medium text-primary">
|
||||
{normalizeIl(secim.name) === seciliIl
|
||||
? "Filtreyi kaldırmak için tıkla"
|
||||
: "Filtrelemek için tıkla"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-2 flex items-center justify-end gap-2 text-xs text-slate-500">
|
||||
<span>az</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-24 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, oklch(0.623 0.188 259.8 / 0.3), oklch(0.623 0.188 259.8))",
|
||||
}}
|
||||
/>
|
||||
<span>çok program</span>
|
||||
</div>
|
||||
|
||||
{haritaDisi.length > 0 ? (
|
||||
<p className="mt-3 text-xs text-slate-500">
|
||||
Harita dışı:{" "}
|
||||
{haritaDisi
|
||||
.map(
|
||||
([il, ozet]) => `${il.toLocaleLowerCase("tr-TR")} (${toplam(ozet)})`
|
||||
)
|
||||
.join(", ")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/components/core/text-loop.tsx
Normal file
68
src/components/core/text-loop.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
// motion-primitives TextLoop — https://motion-primitives.com/docs/text-loop
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
AnimatePresence,
|
||||
motion,
|
||||
type Transition,
|
||||
type Variants,
|
||||
} from "motion/react";
|
||||
import { Children, useEffect, useState } from "react";
|
||||
|
||||
export type TextLoopProps = {
|
||||
children: React.ReactNode[];
|
||||
className?: string;
|
||||
interval?: number;
|
||||
transition?: Transition;
|
||||
variants?: Variants;
|
||||
onIndexChange?: (index: number) => void;
|
||||
};
|
||||
|
||||
const varsayilanVaryantlar: Variants = {
|
||||
initial: { y: 20, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1 },
|
||||
exit: { y: -20, opacity: 0 },
|
||||
};
|
||||
|
||||
export function TextLoop({
|
||||
children,
|
||||
className,
|
||||
interval = 2,
|
||||
transition = { duration: 0.3 },
|
||||
variants,
|
||||
onIndexChange,
|
||||
}: TextLoopProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const items = Children.toArray(children);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalMs = interval * 1000;
|
||||
const timer = setInterval(() => {
|
||||
setCurrentIndex((current) => {
|
||||
const next = (current + 1) % items.length;
|
||||
onIndexChange?.(next);
|
||||
return next;
|
||||
});
|
||||
}, intervalMs);
|
||||
return () => clearInterval(timer);
|
||||
}, [items.length, interval, onIndexChange]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative inline-block whitespace-nowrap", className)}>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.div
|
||||
key={currentIndex}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={transition}
|
||||
variants={variants ?? varsayilanVaryantlar}
|
||||
>
|
||||
{items[currentIndex]}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,7 +48,9 @@ export function HeroForm() {
|
||||
}
|
||||
}
|
||||
|
||||
// Seçimler tamam: sonuç sayfası (ve sonrası) kullanabilsin diye sakla, yönlendir.
|
||||
// Seçimler tamam: sakla ve giriş kapısına yönlendir. Girişli kullanıcıyı
|
||||
// /giris zaten callback'e (sonuç + otomatik üretim) sektirir; girişsiz
|
||||
// kullanıcı listeyi görmeden önce funnel'lı giriş ekranıyla karşılaşır.
|
||||
function secimleriTamamla(secimler: SihirbazSecimleri) {
|
||||
if (!sira) return;
|
||||
try {
|
||||
@@ -58,7 +60,8 @@ export function HeroForm() {
|
||||
);
|
||||
} catch {}
|
||||
setModalAcik(false);
|
||||
router.push(`/sonuc?sira=${sira}&tur=${TUR}`);
|
||||
const geri = `/sonuc?sira=${sira}&tur=${TUR}&sihirbaz=1`;
|
||||
router.push(`/giris?callback=${encodeURIComponent(geri)}`);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
96
src/components/il-secim-haritasi.tsx
Normal file
96
src/components/il-secim-haritasi.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
// Sihirbazın "Nerede okumak istersin?" adımı: çip listesi yerine dokunarak
|
||||
// il seçilen Türkiye haritası. Mavi ton ildeki program sayısını, turuncu
|
||||
// kontur seçimi gösterir. KKTC gibi harita dışı yerler çip olarak eklenir.
|
||||
|
||||
import { cities } from "turkey-map-react/lib/data";
|
||||
import { normalizeIlAdi } from "@/lib/harita";
|
||||
|
||||
export function IlSecimHaritasi({
|
||||
iller,
|
||||
secili,
|
||||
onToggle,
|
||||
}: {
|
||||
/** Bu sıralama penceresinde programı olan iller (DB biçimi ad + adet) */
|
||||
iller: { il: string; adet: number }[];
|
||||
secili: string[];
|
||||
onToggle: (il: string) => void;
|
||||
}) {
|
||||
const adetler = new Map(iller.map((i) => [i.il, i.adet]));
|
||||
const maxAdet = Math.max(1, ...iller.map((i) => i.adet));
|
||||
const seciliSet = new Set(secili);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<svg
|
||||
viewBox="0 0 1050 585"
|
||||
className="h-auto w-full"
|
||||
role="group"
|
||||
aria-label="Okumak istediğin illeri haritadan seç"
|
||||
>
|
||||
{/* Seçili iller en son çizilir ki turuncu kontur komşularca örtülmesin */}
|
||||
{[...cities]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(seciliSet.has(normalizeIlAdi(a.name))) -
|
||||
Number(seciliSet.has(normalizeIlAdi(b.name))),
|
||||
)
|
||||
.map((city) => {
|
||||
const key = normalizeIlAdi(city.name);
|
||||
const adet = adetler.get(key);
|
||||
const seciliMi = seciliSet.has(key);
|
||||
const yogunluk = adet ? adet / maxAdet : 0;
|
||||
return (
|
||||
<path
|
||||
key={city.id}
|
||||
d={city.path}
|
||||
className={
|
||||
adet
|
||||
? "cursor-pointer transition-opacity duration-200 hover:opacity-75"
|
||||
: undefined
|
||||
}
|
||||
fill={
|
||||
seciliMi
|
||||
? "oklch(0.705 0.213 47.6)" // orange-500
|
||||
: adet
|
||||
? `oklch(0.623 0.188 259.8 / ${0.25 + 0.65 * yogunluk})`
|
||||
: "oklch(0.929 0.013 255.5)" // slate-200
|
||||
}
|
||||
stroke={seciliMi ? "oklch(0.553 0.195 38.4)" : "white"}
|
||||
strokeWidth={seciliMi ? 2 : 1}
|
||||
onClick={adet ? () => onToggle(key) : undefined}
|
||||
role={adet ? "button" : undefined}
|
||||
aria-pressed={adet ? seciliMi : undefined}
|
||||
>
|
||||
<title>
|
||||
{adet
|
||||
? `${city.name}: ${adet} program — ${seciliMi ? "seçimi kaldır" : "seçmek için tıkla"}`
|
||||
: `${city.name}: bu sıralamada program yok`}
|
||||
</title>
|
||||
</path>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<div className="mt-2 flex items-center justify-end gap-2 text-xs text-slate-500">
|
||||
<span>az</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-24 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, oklch(0.623 0.188 259.8 / 0.25), oklch(0.623 0.188 259.8 / 0.9))",
|
||||
}}
|
||||
/>
|
||||
<span>çok program</span>
|
||||
<span className="ml-3 inline-flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-2.5 rounded-full bg-orange-500"
|
||||
/>
|
||||
seçili
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
411
src/components/manuel-liste/liste-cekmecesi.tsx
Normal file
411
src/components/manuel-liste/liste-cekmecesi.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
"use client";
|
||||
|
||||
// Manuel 24'lük listenin alt çekmecesi (bottom sheet) + "+"dan listeye uçan
|
||||
// pill katmanı. Layout'ta bir kez monte edilir; navbar'daki Listem butonu
|
||||
// açar/kapatır.
|
||||
//
|
||||
// Motion dili (apple-design / emil-design-eng):
|
||||
// - Sheet alttan spring ile yükselir; çıkış girişten hızlı, iOS drawer eğrisi.
|
||||
// - Üstteki tutamak çubuğundan aşağı çekilince kapanır (offset veya hız eşiği).
|
||||
// - Satır sıralama yalnız grip tutamağından başlar (dragControls); gövdeyi
|
||||
// sağa kaydırmak silme jesti — eşik aşılırsa satır uçar, altından kırmızı
|
||||
// sil bandı görünür.
|
||||
// - Uçuş pill'i momentumsuz doğduğu için sekmeden (bounce ~0) hedefe süzülür.
|
||||
// - prefers-reduced-motion: uçuş yok, sheet cross-fade; silme için X butonu
|
||||
// her durumda kalır.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
GripVertical,
|
||||
ListChecks,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
AnimatePresence,
|
||||
motion,
|
||||
Reorder,
|
||||
useDragControls,
|
||||
useMotionValue,
|
||||
useReducedMotion,
|
||||
useTransform,
|
||||
type PanInfo,
|
||||
} from "motion/react";
|
||||
import { RISK_ETIKET, type RiskSeviyesi } from "@/lib/risk";
|
||||
import {
|
||||
MANUEL_LISTE_MAX,
|
||||
cekmeceKapat,
|
||||
cikar,
|
||||
temizle,
|
||||
ucusAbone,
|
||||
useCekmeceAcik,
|
||||
useManuelListe,
|
||||
yenidenSirala,
|
||||
type ManuelTercih,
|
||||
type UcusIstegi,
|
||||
} from "./store";
|
||||
|
||||
// Risk renk dili ürünün geri kalanıyla birebir aynı (bkz. rapor-listesi)
|
||||
const RISK_STIL: Record<RiskSeviyesi, { nokta: string; kenar: string }> = {
|
||||
guvenli: { nokta: "bg-emerald-500", kenar: "border-l-emerald-500" },
|
||||
"az-riskli": { nokta: "bg-amber-500", kenar: "border-l-amber-500" },
|
||||
riskli: { nokta: "bg-red-500", kenar: "border-l-red-500" },
|
||||
};
|
||||
|
||||
export function ListeCekmecesi() {
|
||||
const liste = useManuelListe();
|
||||
const acik = useCekmeceAcik();
|
||||
const azMotion = useReducedMotion();
|
||||
const sheetKontrol = useDragControls();
|
||||
|
||||
// Escape ile kapat — çekmece modal değil, akışı bölmeden yaşar
|
||||
useEffect(() => {
|
||||
if (!acik) return;
|
||||
const dinle = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") cekmeceKapat();
|
||||
};
|
||||
window.addEventListener("keydown", dinle);
|
||||
return () => window.removeEventListener("keydown", dinle);
|
||||
}, [acik]);
|
||||
|
||||
const doluluk = Math.round((liste.length / MANUEL_LISTE_MAX) * 100);
|
||||
|
||||
const surukleyinceKapat = (_: unknown, info: PanInfo) => {
|
||||
if (info.offset.y > 80 || info.velocity.y > 500) cekmeceKapat();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<UcusKatmani />
|
||||
|
||||
<AnimatePresence>
|
||||
{acik ? (
|
||||
<motion.aside
|
||||
key="cekmece"
|
||||
initial={azMotion ? { opacity: 0 } : { y: "calc(100% + 16px)" }}
|
||||
animate={azMotion ? { opacity: 1 } : { y: 0 }}
|
||||
exit={
|
||||
azMotion
|
||||
? { opacity: 0, transition: { duration: 0.15 } }
|
||||
: {
|
||||
y: "calc(100% + 16px)",
|
||||
transition: {
|
||||
duration: 0.25,
|
||||
ease: [0.32, 0.72, 0, 1],
|
||||
},
|
||||
}
|
||||
}
|
||||
transition={{ type: "spring", duration: 0.4, bounce: 0.15 }}
|
||||
// layoutRoot: sheet'in kendi y-transform animasyonu, içindeki
|
||||
// Reorder satırlarının layout ölçümünü bozmasın (satırlar aksi
|
||||
// halde giriş anındaki offset'i kalıcı transform olarak taşıyor)
|
||||
layoutRoot
|
||||
drag={azMotion ? false : "y"}
|
||||
dragListener={false}
|
||||
dragControls={sheetKontrol}
|
||||
dragConstraints={{ top: 0, bottom: 0 }}
|
||||
dragElastic={{ top: 0, bottom: 0.6 }}
|
||||
onDragEnd={surukleyinceKapat}
|
||||
aria-label="Manuel tercih listem"
|
||||
className="fixed inset-x-0 bottom-0 z-[60] flex max-h-[85dvh] w-full justify-center p-3"
|
||||
>
|
||||
<div className="flex min-h-0 w-full max-w-lg flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl">
|
||||
{/* Tutamak: buradan aşağı çekilince sheet kapanır */}
|
||||
<div
|
||||
onPointerDown={(e) => {
|
||||
if (!azMotion) sheetKontrol.start(e);
|
||||
}}
|
||||
className="flex cursor-grab touch-none justify-center pb-1 pt-3 active:cursor-grabbing"
|
||||
>
|
||||
<div className="h-1 w-10 rounded-full bg-slate-300" aria-hidden />
|
||||
</div>
|
||||
|
||||
{/* Başlık + doluluk */}
|
||||
<div className="border-b border-slate-100 px-5 pb-4 pt-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 font-heading text-lg font-bold">
|
||||
<ListChecks className="size-5 text-primary" aria-hidden />
|
||||
Listem
|
||||
<span
|
||||
data-cekmece-hedef
|
||||
className="font-heading text-sm font-bold tabular-nums text-slate-400"
|
||||
>
|
||||
{liste.length}/{MANUEL_LISTE_MAX}
|
||||
</span>
|
||||
</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
{liste.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={temizle}
|
||||
title="Listeyi temizle"
|
||||
className="cursor-pointer rounded-full p-2 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
<span className="sr-only">Listeyi temizle</span>
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={cekmeceKapat}
|
||||
className="cursor-pointer rounded-full p-2 text-slate-400 transition-colors duration-200 hover:bg-slate-100 hover:text-slate-600"
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
<span className="sr-only">Kapat</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mt-3 h-1.5 overflow-hidden rounded-full bg-slate-100"
|
||||
role="progressbar"
|
||||
aria-valuenow={liste.length}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={MANUEL_LISTE_MAX}
|
||||
aria-label="Liste doluluğu"
|
||||
>
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-primary"
|
||||
animate={{ width: `${doluluk}%` }}
|
||||
transition={{ type: "spring", duration: 0.5, bounce: 0 }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
Sıralamanla ulaşabildiğin programlardan kendi listen —
|
||||
tutamaktan sürükleyip sırala, sağa kaydırıp sil.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Gövde: sürüklenebilir satırlar */}
|
||||
{liste.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 px-8 py-12 text-center">
|
||||
<ListChecks className="size-8 text-slate-300" aria-hidden />
|
||||
<p className="font-heading text-sm font-bold text-slate-700">
|
||||
Listen henüz boş
|
||||
</p>
|
||||
<p className="text-xs leading-relaxed text-slate-500">
|
||||
Sonuç sayfasındaki tablodan <span aria-hidden>+</span> ile
|
||||
program ekle; 24 tercihlik listen burada birikir.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={liste}
|
||||
onReorder={yenidenSirala}
|
||||
layoutScroll
|
||||
as="ol"
|
||||
className="min-h-0 flex-1 space-y-2 overflow-y-auto px-4 py-4"
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{liste.map((t, i) => (
|
||||
<CekmeceSatiri key={t.id} tercih={t} sira={i + 1} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
)}
|
||||
|
||||
{/* Alt bant: AI danışman köprüsü */}
|
||||
<div className="border-t border-slate-100 px-5 py-4">
|
||||
<Link
|
||||
href="/listem"
|
||||
onClick={cekmeceKapat}
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary transition-colors duration-200 hover:text-primary/80"
|
||||
>
|
||||
<Sparkles className="size-4" aria-hidden />
|
||||
AI listeni ve danışmanı aç
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</motion.aside>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sağa kaydırıp silme eşiği (px) — bunu aşınca satır listeden çıkar */
|
||||
const SIL_ESIK = 90;
|
||||
|
||||
function CekmeceSatiri({
|
||||
tercih,
|
||||
sira,
|
||||
}: {
|
||||
tercih: ManuelTercih;
|
||||
sira: number;
|
||||
}) {
|
||||
const stil = tercih.risk ? RISK_STIL[tercih.risk] : null;
|
||||
const siraKontrol = useDragControls();
|
||||
|
||||
// Kartın yatay konumu: sil bandının görünürlüğü buna bağlı
|
||||
const x = useMotionValue(0);
|
||||
const silGorunur = useTransform(x, [0, SIL_ESIK / 2], [0, 1]);
|
||||
|
||||
const kaydirinca = (_: unknown, info: PanInfo) => {
|
||||
if (
|
||||
info.offset.x > SIL_ESIK ||
|
||||
(info.offset.x > 32 && info.velocity.x > 500)
|
||||
) {
|
||||
cikar(tercih.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
value={tercih}
|
||||
as="li"
|
||||
layout="position"
|
||||
layoutId={`manuel-tercih-${tercih.id}`}
|
||||
dragListener={false}
|
||||
dragControls={siraKontrol}
|
||||
initial={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 96, transition: { duration: 0.18 } }}
|
||||
transition={{ type: "spring", duration: 0.4, bounce: 0 }}
|
||||
whileDrag={{
|
||||
scale: 1.03,
|
||||
boxShadow: "0 12px 32px rgba(15, 23, 42, 0.16)",
|
||||
zIndex: 10,
|
||||
}}
|
||||
className="relative select-none"
|
||||
>
|
||||
{/* Sil bandı: kart sağa kaydıkça altından görünür */}
|
||||
<motion.div
|
||||
style={{ opacity: silGorunur }}
|
||||
className="absolute inset-0 flex items-center rounded-xl bg-red-500 pl-4"
|
||||
aria-hidden
|
||||
>
|
||||
<Trash2 className="size-4 text-white" />
|
||||
</motion.div>
|
||||
|
||||
{/* Kart gövdesi: sağa kaydırılabilir (sola kilitli) */}
|
||||
<motion.div
|
||||
drag="x"
|
||||
style={{ x }}
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={{ left: 0, right: 0.7 }}
|
||||
dragSnapToOrigin
|
||||
onDragEnd={kaydirinca}
|
||||
className={`relative flex items-center gap-2.5 rounded-xl border border-l-4 border-slate-200 bg-white px-3 py-2.5 ${
|
||||
stil?.kenar ?? "border-l-slate-300"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation(); // yatay kaydırmayı tetiklemesin
|
||||
siraKontrol.start(e);
|
||||
}}
|
||||
className="-m-1 shrink-0 cursor-grab touch-none rounded p-1 text-slate-300 active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical className="size-4" aria-hidden />
|
||||
<span className="sr-only">{tercih.isim} sırasını değiştir</span>
|
||||
</button>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-slate-100 font-heading text-[11px] font-bold tabular-nums">
|
||||
{sira}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold">
|
||||
{tercih.isim}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-slate-500">
|
||||
{tercih.universite}
|
||||
{tercih.il ? ` · ${tercih.il.toLocaleLowerCase("tr-TR")}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
{tercih.efektifSira != null ? (
|
||||
<span className="shrink-0 text-xs tabular-nums text-slate-500">
|
||||
~{tercih.efektifSira.toLocaleString("tr-TR")}.
|
||||
</span>
|
||||
) : null}
|
||||
{tercih.risk ? (
|
||||
<span
|
||||
className={`size-2.5 shrink-0 rounded-full ${RISK_STIL[tercih.risk].nokta}`}
|
||||
aria-label={RISK_ETIKET[tercih.risk]}
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cikar(tercih.id)}
|
||||
onPointerDown={(e) => e.stopPropagation()} // kaydırmayı tetiklemesin
|
||||
className="shrink-0 cursor-pointer rounded-full p-1.5 text-slate-300 transition-colors duration-200 hover:bg-slate-100 hover:text-red-500"
|
||||
>
|
||||
<X className="size-3.5" aria-hidden />
|
||||
<span className="sr-only">{tercih.isim} listeden çıkar</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Uçuş katmanı: "+" butonundan Listem hedefine süzülen pill
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Ucus = UcusIstegi & { id: number; hedef: { x: number; y: number } };
|
||||
|
||||
let ucusSayac = 0;
|
||||
|
||||
/** Hedef nokta: çekmece açıksa içindeki sayaç, değilse navbar'daki buton. */
|
||||
function hedefBul(): { x: number; y: number } | null {
|
||||
const el =
|
||||
document.querySelector("[data-cekmece-hedef]") ??
|
||||
document.querySelector("[data-listem-hedef]");
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
||||
}
|
||||
|
||||
function UcusKatmani() {
|
||||
const [ucuslar, setUcuslar] = useState<Ucus[]>([]);
|
||||
const azMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (azMotion) return; // reduced motion: rozet pulse yeter
|
||||
return ucusAbone((istek) => {
|
||||
const hedef = hedefBul();
|
||||
if (!hedef) return;
|
||||
setUcuslar((u) => [...u, { ...istek, id: ucusSayac++, hedef }]);
|
||||
});
|
||||
}, [azMotion]);
|
||||
|
||||
if (ucuslar.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none fixed inset-0 z-[70] print:hidden"
|
||||
aria-hidden
|
||||
>
|
||||
{ucuslar.map((u) => (
|
||||
<motion.div
|
||||
key={u.id}
|
||||
initial={{ x: u.kaynak.x, y: u.kaynak.y, scale: 1, opacity: 1 }}
|
||||
animate={{
|
||||
x: u.hedef.x,
|
||||
y: u.hedef.y,
|
||||
scale: 0.3,
|
||||
opacity: 0.4,
|
||||
}}
|
||||
transition={{ type: "spring", duration: 0.55, bounce: 0 }}
|
||||
onAnimationComplete={() =>
|
||||
setUcuslar((mevcut) => mevcut.filter((x) => x.id !== u.id))
|
||||
}
|
||||
className="absolute left-0 top-0"
|
||||
>
|
||||
{/* Merkezleme iç sarmalayıcıda: motion'ın x/y transformu ile çakışmasın */}
|
||||
<div className="flex max-w-56 -translate-x-1/2 -translate-y-1/2 items-center gap-2 rounded-full border border-slate-200 bg-white py-1.5 pl-3 pr-4 shadow-lg">
|
||||
{u.tercih.risk ? (
|
||||
<span
|
||||
className={`size-2 shrink-0 rounded-full ${RISK_STIL[u.tercih.risk].nokta}`}
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate text-xs font-semibold text-slate-700">
|
||||
{u.tercih.isim}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/components/manuel-liste/listem-butonu.tsx
Normal file
47
src/components/manuel-liste/listem-butonu.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
// Navbar'daki "Listem" butonu: manuel listenin sayacını taşır, tıklanınca
|
||||
// çekmeceyi açar/kapatır. Uçuş animasyonunun kapalı-çekmece hedefi de budur
|
||||
// (data-listem-hedef). Sayaç değişince rozet spring ile "pulse" yapar.
|
||||
|
||||
import { ListChecks } from "lucide-react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import {
|
||||
MANUEL_LISTE_MAX,
|
||||
cekmeceDegistir,
|
||||
useCekmeceAcik,
|
||||
useManuelListe,
|
||||
} from "./store";
|
||||
|
||||
export function ListemButonu() {
|
||||
const liste = useManuelListe();
|
||||
const acik = useCekmeceAcik();
|
||||
const azMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-listem-hedef
|
||||
onClick={cekmeceDegistir}
|
||||
aria-expanded={acik}
|
||||
aria-label={`Listem — ${liste.length}/${MANUEL_LISTE_MAX} tercih`}
|
||||
title="24'lük tercih listen"
|
||||
className="relative inline-flex h-12 cursor-pointer items-center gap-2 whitespace-nowrap rounded-full border border-slate-200 bg-white px-5 text-sm font-semibold text-slate-700 transition-[color,background-color,border-color,transform] duration-200 hover:border-slate-300 hover:bg-slate-50 active:scale-[0.97]"
|
||||
>
|
||||
<ListChecks className="size-4 text-primary" aria-hidden />
|
||||
<span className="hidden sm:inline">Listem</span>
|
||||
{liste.length > 0 ? (
|
||||
// key=length: her eklemede rozet yeniden doğar ve spring ile oturur
|
||||
<motion.span
|
||||
key={liste.length}
|
||||
initial={azMotion ? false : { scale: 0.6 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", duration: 0.4, bounce: 0.45 }}
|
||||
className="flex size-5 items-center justify-center rounded-full bg-orange-500 font-heading text-[11px] font-bold tabular-nums text-white"
|
||||
>
|
||||
{liste.length}
|
||||
</motion.span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
76
src/components/manuel-liste/manuel-harita.tsx
Normal file
76
src/components/manuel-liste/manuel-harita.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
// Manuel 24'lük listenin coğrafyası: tablodan "+" ile eklenen her programın
|
||||
// üniversitesi gerçek kampüs konumunda risk renkli pinlenir. Liste store'una
|
||||
// abonedir; ekleme/çıkarma/sürükleme anında haritaya yansır.
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { TercihHaritasi, type HaritaPin } from "@/components/tercih-haritasi";
|
||||
import { ilKonum, uniAdiNormalize, uniKonum } from "@/lib/harita";
|
||||
import type { RiskSeviyesi } from "@/lib/risk";
|
||||
import { useManuelListe } from "./store";
|
||||
|
||||
// Aynı üniversitede birden çok tercih varsa pinde kötü risk baskın olsun
|
||||
const RISK_AGIRLIK: Record<RiskSeviyesi, number> = {
|
||||
guvenli: 0,
|
||||
"az-riskli": 1,
|
||||
riskli: 2,
|
||||
};
|
||||
|
||||
export function ManuelHarita() {
|
||||
const liste = useManuelListe();
|
||||
const [seciliIl, setSeciliIl] = useState<string | null>(null);
|
||||
|
||||
const { pinler, haritaDisi } = useMemo(() => {
|
||||
const gruplar = new Map<string, HaritaPin>();
|
||||
let haritaDisi = 0;
|
||||
|
||||
liste.forEach((t, i) => {
|
||||
const konum = uniKonum(t.universite, t.il) ?? ilKonum(t.il);
|
||||
if (!konum) {
|
||||
haritaDisi += 1;
|
||||
return;
|
||||
}
|
||||
const risk = t.risk ?? "az-riskli";
|
||||
const key = `${uniAdiNormalize(t.universite)}|${t.il ?? ""}`;
|
||||
const mevcut = gruplar.get(key);
|
||||
if (mevcut) {
|
||||
mevcut.tercihSiralari.push(i + 1);
|
||||
if (RISK_AGIRLIK[risk] > RISK_AGIRLIK[mevcut.risk]) mevcut.risk = risk;
|
||||
} else {
|
||||
gruplar.set(key, {
|
||||
id: key,
|
||||
universite: t.universite,
|
||||
il: t.il,
|
||||
x: konum.x,
|
||||
y: konum.y,
|
||||
tercihSiralari: [i + 1],
|
||||
risk,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { pinler: [...gruplar.values()], haritaDisi };
|
||||
}, [liste]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TercihHaritasi
|
||||
pinler={pinler}
|
||||
seciliIl={seciliIl}
|
||||
onSeciliIlDegisti={setSeciliIl}
|
||||
/>
|
||||
{haritaDisi > 0 ? (
|
||||
<p className="mt-1 px-1 text-xs text-slate-500">
|
||||
{haritaDisi} tercih harita dışında (KKTC vb.)
|
||||
</p>
|
||||
) : null}
|
||||
{liste.length === 0 ? (
|
||||
<p className="mt-2 text-center text-xs text-slate-500">
|
||||
Tablodan <span aria-hidden>+</span> ile ekledikçe üniversiteler
|
||||
haritada belirir.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
src/components/manuel-liste/store.ts
Normal file
168
src/components/manuel-liste/store.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
// Manuel 24'lük liste — AI'dan bağımsız, kullanıcının tablodan "+" ile kendi
|
||||
// kurduğu tercih listesi. Kaynak tek: bu modüldeki harici store. Navbar rozeti,
|
||||
// çekmece ve program tablosu aynı store'a abone olur; kalıcılık localStorage'ta
|
||||
// (girişsiz kullanıcı da liste kurabilsin diye sunucuya yazmıyoruz).
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { RiskSeviyesi } from "@/lib/risk";
|
||||
|
||||
export const MANUEL_LISTE_MAX = 24;
|
||||
const STORAGE_KEY = "kolaytercih.manuel-liste.v1";
|
||||
|
||||
export type ManuelTercih = {
|
||||
/** yokatlas program id'si */
|
||||
id: string;
|
||||
isim: string;
|
||||
universite: string;
|
||||
il: string | null;
|
||||
efektifSira: number | null;
|
||||
/** Ekleme anındaki aday sıralamasına göre risk (renklendirme için) */
|
||||
risk: RiskSeviyesi | null;
|
||||
};
|
||||
|
||||
/** "+" butonundan listeye uçan pill'in tek seferlik isteği */
|
||||
export type UcusIstegi = {
|
||||
tercih: ManuelTercih;
|
||||
/** Kaynak butonun viewport koordinatları */
|
||||
kaynak: { x: number; y: number };
|
||||
};
|
||||
|
||||
// SSR snapshot'ı: her çağrıda aynı referans dönmeli, yoksa React döngüye girer
|
||||
const BOS_LISTE: ManuelTercih[] = [];
|
||||
|
||||
let liste: ManuelTercih[] = BOS_LISTE;
|
||||
let cekmeceAcik = false;
|
||||
let yuklendi = false;
|
||||
|
||||
const dinleyiciler = new Set<() => void>();
|
||||
const ucusDinleyiciler = new Set<(istek: UcusIstegi) => void>();
|
||||
|
||||
function yayinla() {
|
||||
for (const cb of dinleyiciler) cb();
|
||||
}
|
||||
|
||||
function kaydet() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(liste));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function dogrula(ham: unknown): ManuelTercih[] {
|
||||
if (!Array.isArray(ham)) return [];
|
||||
return ham
|
||||
.filter(
|
||||
(t): t is ManuelTercih =>
|
||||
typeof t === "object" &&
|
||||
t !== null &&
|
||||
typeof (t as ManuelTercih).id === "string" &&
|
||||
typeof (t as ManuelTercih).isim === "string",
|
||||
)
|
||||
.slice(0, MANUEL_LISTE_MAX);
|
||||
}
|
||||
|
||||
function yukle() {
|
||||
if (yuklendi || typeof window === "undefined") return;
|
||||
yuklendi = true;
|
||||
try {
|
||||
const ham = localStorage.getItem(STORAGE_KEY);
|
||||
if (ham) liste = dogrula(JSON.parse(ham));
|
||||
} catch {}
|
||||
// Başka sekmede yapılan değişiklikleri de yansıt
|
||||
window.addEventListener("storage", (e) => {
|
||||
if (e.key !== STORAGE_KEY) return;
|
||||
try {
|
||||
liste = e.newValue ? dogrula(JSON.parse(e.newValue)) : [];
|
||||
yayinla();
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
|
||||
function abone(cb: () => void) {
|
||||
dinleyiciler.add(cb);
|
||||
if (!yuklendi) {
|
||||
// İlk client abonesi: storage'ı oku ve snapshot'ı tazele
|
||||
yukle();
|
||||
queueMicrotask(yayinla);
|
||||
}
|
||||
return () => {
|
||||
dinleyiciler.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
export function useManuelListe(): ManuelTercih[] {
|
||||
return useSyncExternalStore(
|
||||
abone,
|
||||
() => liste,
|
||||
() => BOS_LISTE,
|
||||
);
|
||||
}
|
||||
|
||||
export function useCekmeceAcik(): boolean {
|
||||
return useSyncExternalStore(
|
||||
abone,
|
||||
() => cekmeceAcik,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
export function listedeMi(id: string): boolean {
|
||||
return liste.some((t) => t.id === id);
|
||||
}
|
||||
|
||||
/** Listeye ekler; doluysa veya zaten varsa false döner. */
|
||||
export function ekle(tercih: ManuelTercih): boolean {
|
||||
yukle();
|
||||
if (liste.length >= MANUEL_LISTE_MAX || listedeMi(tercih.id)) return false;
|
||||
liste = [...liste, tercih];
|
||||
kaydet();
|
||||
yayinla();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function cikar(id: string) {
|
||||
liste = liste.filter((t) => t.id !== id);
|
||||
kaydet();
|
||||
yayinla();
|
||||
}
|
||||
|
||||
/** Sürükle-bırak sonrası yeni sıra (motion Reorder'dan gelir) */
|
||||
export function yenidenSirala(yeni: ManuelTercih[]) {
|
||||
liste = yeni;
|
||||
kaydet();
|
||||
yayinla();
|
||||
}
|
||||
|
||||
export function temizle() {
|
||||
liste = [];
|
||||
kaydet();
|
||||
yayinla();
|
||||
}
|
||||
|
||||
export function cekmeceAc() {
|
||||
cekmeceAcik = true;
|
||||
yayinla();
|
||||
}
|
||||
|
||||
export function cekmeceKapat() {
|
||||
cekmeceAcik = false;
|
||||
yayinla();
|
||||
}
|
||||
|
||||
export function cekmeceDegistir() {
|
||||
cekmeceAcik = !cekmeceAcik;
|
||||
yayinla();
|
||||
}
|
||||
|
||||
/** "+" butonu uçuş animasyonu tetikler; katman (liste-cekmecesi) dinler. */
|
||||
export function ucusBaslat(istek: UcusIstegi) {
|
||||
for (const cb of ucusDinleyiciler) cb(istek);
|
||||
}
|
||||
|
||||
export function ucusAbone(cb: (istek: UcusIstegi) => void) {
|
||||
ucusDinleyiciler.add(cb);
|
||||
return () => {
|
||||
ucusDinleyiciler.delete(cb);
|
||||
};
|
||||
}
|
||||
253
src/components/program-tablosu.tsx
Normal file
253
src/components/program-tablosu.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
"use client";
|
||||
|
||||
// Sonuç sayfasının AI'sız ham listesi: yalnızca başarı sıralamasıyla
|
||||
// yokatlas'tan gelen programlar, dilim sekmeleri halinde. Her satırın "+"
|
||||
// butonu programı manuel 24'lük listeye ekler; pill navbar'daki (ya da açık
|
||||
// çekmecedeki) Listem hedefine uçar (bkz. manuel-liste/liste-cekmecesi).
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Plus, Rocket, Scale, ShieldCheck } from "lucide-react";
|
||||
import type { Program, RankResults } from "@/lib/db";
|
||||
import { riskHesapla, type RiskSeviyesi } from "@/lib/risk";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
MANUEL_LISTE_MAX,
|
||||
ekle,
|
||||
ucusBaslat,
|
||||
useManuelListe,
|
||||
type ManuelTercih,
|
||||
} from "@/components/manuel-liste/store";
|
||||
import { ManuelHarita } from "@/components/manuel-liste/manuel-harita";
|
||||
|
||||
type DilimKey = keyof RankResults;
|
||||
|
||||
const DILIMLER: {
|
||||
key: DilimKey;
|
||||
etiket: string;
|
||||
aciklama: string;
|
||||
icon: typeof Rocket;
|
||||
renk: string;
|
||||
}[] = [
|
||||
{
|
||||
key: "hayal",
|
||||
etiket: "Hayal",
|
||||
aciklama: "Tabanı sıralamanın üstünde — şansını denediğin satırlar.",
|
||||
icon: Rocket,
|
||||
renk: "text-red-500",
|
||||
},
|
||||
{
|
||||
key: "dengeli",
|
||||
etiket: "Dengeli",
|
||||
aciklama: "Tabanı sıralamana denk — listenin bel kemiği.",
|
||||
icon: Scale,
|
||||
renk: "text-amber-500",
|
||||
},
|
||||
{
|
||||
key: "garanti",
|
||||
etiket: "Garanti",
|
||||
aciklama: "Tabanı belirgin şekilde altında — güvenli bölge.",
|
||||
icon: ShieldCheck,
|
||||
renk: "text-emerald-600",
|
||||
},
|
||||
];
|
||||
|
||||
const RISK_NOKTA: Record<RiskSeviyesi, string> = {
|
||||
guvenli: "bg-emerald-500",
|
||||
"az-riskli": "bg-amber-500",
|
||||
riskli: "bg-red-500",
|
||||
};
|
||||
|
||||
function efektifSira(p: Program): number | null {
|
||||
return p.sira2025 ?? p.sira2024;
|
||||
}
|
||||
|
||||
function tercihYap(p: Program, adaySira: number): ManuelTercih {
|
||||
const taban = efektifSira(p);
|
||||
return {
|
||||
id: p.id,
|
||||
isim: p.isim,
|
||||
universite: p.universite,
|
||||
il: p.il,
|
||||
efektifSira: taban,
|
||||
risk: riskHesapla(taban, adaySira),
|
||||
};
|
||||
}
|
||||
|
||||
export function ProgramTablosu({
|
||||
sonuclar,
|
||||
adaySira,
|
||||
}: {
|
||||
sonuclar: RankResults;
|
||||
adaySira: number;
|
||||
}) {
|
||||
const liste = useManuelListe();
|
||||
const [aktifDilim, setAktifDilim] = useState<DilimKey>("dengeli");
|
||||
const listedekiler = new Set(liste.map((t) => t.id));
|
||||
const doldu = liste.length >= MANUEL_LISTE_MAX;
|
||||
|
||||
function eklemeyiDene(p: Program, buton: HTMLElement) {
|
||||
const tercih = tercihYap(p, adaySira);
|
||||
if (!ekle(tercih)) return;
|
||||
const r = buton.getBoundingClientRect();
|
||||
ucusBaslat({
|
||||
tercih,
|
||||
kaynak: { x: r.left + r.width / 2, y: r.top + r.height / 2 },
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-16" aria-label="Sıralamanla açılan programlar">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-bold">
|
||||
Sıralamanla açılan programlar
|
||||
</h2>
|
||||
<p className="mt-1 max-w-2xl text-sm text-slate-500">
|
||||
Yapay zekâsız, ham YÖK Atlas verisi: geçen yılın taban
|
||||
sıralamalarına göre ulaşabildiğin bölümler.{" "}
|
||||
<span className="font-medium text-slate-700">
|
||||
+ ile beğendiklerini kendi 24'lük listene at.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Listendeki üniversitelerin konumu — ekledikçe canlı dolar */}
|
||||
<div className="mt-5">
|
||||
<ManuelHarita />
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={aktifDilim}
|
||||
onValueChange={(v) => setAktifDilim(v as DilimKey)}
|
||||
className="mt-5"
|
||||
>
|
||||
<TabsList>
|
||||
{DILIMLER.map((d) => (
|
||||
<TabsTrigger key={d.key} value={d.key} className="cursor-pointer">
|
||||
<d.icon className={`size-3.5 ${d.renk}`} aria-hidden />
|
||||
{d.etiket}
|
||||
<span className="text-xs tabular-nums text-slate-400">
|
||||
{sonuclar[d.key].length}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{DILIMLER.map((d) => (
|
||||
<TabsContent key={d.key} value={d.key}>
|
||||
<p className="mt-2 text-xs text-slate-500">{d.aciklama}</p>
|
||||
<div className="mt-3 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
{sonuclar[d.key].length === 0 ? (
|
||||
<p className="px-5 py-8 text-center text-sm text-slate-500">
|
||||
Bu dilimde filtrene uyan program bulunamadı.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8" />
|
||||
<TableHead>Program</TableHead>
|
||||
<TableHead className="text-right">Taban sıra</TableHead>
|
||||
<TableHead className="w-16 text-right">
|
||||
<span className="sr-only">Listeye ekle</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sonuclar[d.key].map((p) => (
|
||||
<ProgramSatiri
|
||||
key={p.id}
|
||||
program={p}
|
||||
adaySira={adaySira}
|
||||
listede={listedekiler.has(p.id)}
|
||||
doldu={doldu}
|
||||
onEkle={eklemeyiDene}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgramSatiri({
|
||||
program: p,
|
||||
adaySira,
|
||||
listede,
|
||||
doldu,
|
||||
onEkle,
|
||||
}: {
|
||||
program: Program;
|
||||
adaySira: number;
|
||||
listede: boolean;
|
||||
doldu: boolean;
|
||||
onEkle: (p: Program, buton: HTMLElement) => void;
|
||||
}) {
|
||||
const taban = efektifSira(p);
|
||||
const risk = riskHesapla(taban, adaySira);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className="pl-4 pr-0">
|
||||
{risk ? (
|
||||
<span
|
||||
className={`block size-2.5 rounded-full ${RISK_NOKTA[risk]}`}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-0 w-full">
|
||||
<span className="block truncate text-sm font-semibold">{p.isim}</span>
|
||||
<span className="block truncate text-xs text-slate-500">
|
||||
{p.universite}
|
||||
{p.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums text-slate-500">
|
||||
{taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right">
|
||||
<button
|
||||
type="button"
|
||||
disabled={listede || doldu}
|
||||
onClick={(e) => onEkle(p, e.currentTarget)}
|
||||
title={
|
||||
listede
|
||||
? "Zaten listende"
|
||||
: doldu
|
||||
? `Liste dolu (${MANUEL_LISTE_MAX}/${MANUEL_LISTE_MAX})`
|
||||
: "Listeme ekle"
|
||||
}
|
||||
className={`inline-flex size-8 cursor-pointer items-center justify-center rounded-full border transition-[background-color,border-color,color,transform] duration-150 active:scale-[0.92] disabled:cursor-default ${
|
||||
listede
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-600"
|
||||
: "border-slate-200 bg-white text-slate-500 hover:border-primary hover:bg-primary/5 hover:text-primary disabled:opacity-40"
|
||||
}`}
|
||||
>
|
||||
{listede ? (
|
||||
<Check className="size-4" aria-hidden />
|
||||
) : (
|
||||
<Plus className="size-4" aria-hidden />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{listede ? `${p.isim} listende` : `${p.isim} listeye ekle`}
|
||||
</span>
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,26 @@
|
||||
import { Rocket, Scale, ShieldCheck } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
"use client";
|
||||
|
||||
// 24'lük listenin gövdesi — metin diyeti sonrası hali:
|
||||
// AI cümleleri satırda değil, satıra dokununca açılan detayda yaşar.
|
||||
// Satır tek bakışta taranır: sıra, program, üniversite·il, taban, trend, risk.
|
||||
// Strateji şeridi paragraf yerine dilim sayıları + kısa uyarı rozetleri basar.
|
||||
//
|
||||
// kilitliBaslangic verilirse o indeksten sonraki satırlar artan blur ile
|
||||
// kilitlenir ve üzerine kilitOverlay bindirilir (paketsiz önizleme).
|
||||
// Kilitli satırların içeriği sunucuda zaten maskelidir (bkz. rapor-maske);
|
||||
// buradaki blur yalnızca görsel dil.
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
MapPin,
|
||||
Minus,
|
||||
Rocket,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import type { RaporSonuc } from "@/lib/ai/rapor";
|
||||
import {
|
||||
RISK_ETIKET,
|
||||
@@ -15,123 +36,295 @@ export const DILIM_ETIKET: Record<string, string> = {
|
||||
};
|
||||
|
||||
// Risk renk dili: yeşil = güvenli, sarı = az riskli, kırmızı = riskli
|
||||
export const RISK_STIL: Record<
|
||||
const RISK_STIL: Record<
|
||||
RiskSeviyesi,
|
||||
{ badge: string; kenar: string; icon: typeof Rocket }
|
||||
{ nokta: string; kenar: string; icon: typeof Rocket }
|
||||
> = {
|
||||
riskli: {
|
||||
badge: "bg-red-100 text-red-700",
|
||||
kenar: "border-l-red-500",
|
||||
icon: Rocket,
|
||||
},
|
||||
riskli: { nokta: "bg-red-500", kenar: "border-l-red-500", icon: Rocket },
|
||||
"az-riskli": {
|
||||
badge: "bg-amber-100 text-amber-700",
|
||||
nokta: "bg-amber-500",
|
||||
kenar: "border-l-amber-500",
|
||||
icon: Scale,
|
||||
},
|
||||
guvenli: {
|
||||
badge: "bg-emerald-100 text-emerald-700",
|
||||
nokta: "bg-emerald-500",
|
||||
kenar: "border-l-emerald-500",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 24'lük listenin gövdesi (genel değerlendirme + tercih kartları).
|
||||
* Hem sonuç modalında hem sohbet sayfasında kullanılır. Risk rengi
|
||||
* adayın sıralamasına göre deterministik hesaplanır (adaySira verilirse);
|
||||
* eski raporlarda dilimden türetilir.
|
||||
*/
|
||||
export function RaporListesi({
|
||||
rapor,
|
||||
adaySira,
|
||||
}: {
|
||||
rapor: RaporSonuc;
|
||||
adaySira?: number;
|
||||
}) {
|
||||
const YILLAR = [2021, 2022, 2023, 2024, 2025];
|
||||
|
||||
type Trend = "zorlasiyor" | "kolaylasiyor" | "stabil";
|
||||
|
||||
/** Deterministik trend: ilk ve son bilinen taban sıralaması kıyaslanır.
|
||||
* Sıralama sayısı küçüldüyse bölüm zorlaşıyor demektir. */
|
||||
function trendYonu(gecmis?: (number | null)[]): Trend | null {
|
||||
const dolu = (gecmis ?? []).filter((s): s is number => s != null);
|
||||
if (dolu.length < 2) return null;
|
||||
const oran = dolu[dolu.length - 1] / dolu[0];
|
||||
if (oran < 0.88) return "zorlasiyor";
|
||||
if (oran > 1.14) return "kolaylasiyor";
|
||||
return "stabil";
|
||||
}
|
||||
|
||||
const TREND_GORUNUM: Record<
|
||||
Trend,
|
||||
{ icon: typeof Minus; etiket: string; renk: string }
|
||||
> = {
|
||||
zorlasiyor: {
|
||||
icon: TrendingUp,
|
||||
etiket: "Taban zorlaşıyor",
|
||||
renk: "text-red-500",
|
||||
},
|
||||
kolaylasiyor: {
|
||||
icon: TrendingDown,
|
||||
etiket: "Taban rahatlıyor",
|
||||
renk: "text-emerald-600",
|
||||
},
|
||||
stabil: { icon: Minus, etiket: "Taban stabil", renk: "text-slate-400" },
|
||||
};
|
||||
|
||||
/** Son 4-5 yılın taban sıralaması mini çizgisi (detay panelinde). */
|
||||
function TrendCizgisi({ gecmis }: { gecmis: (number | null)[] }) {
|
||||
const noktalar = YILLAR.map((yil, i) => ({ yil, sira: gecmis[i] })).filter(
|
||||
(n): n is { yil: number; sira: number } => n.sira != null,
|
||||
);
|
||||
if (noktalar.length < 2) return null;
|
||||
|
||||
const W = 150;
|
||||
const H = 40;
|
||||
const min = Math.min(...noktalar.map((n) => n.sira));
|
||||
const max = Math.max(...noktalar.map((n) => n.sira));
|
||||
const aralik = Math.max(1, max - min);
|
||||
// Küçük sıralama = daha zor bölüm = çizgide yukarıda
|
||||
const nokta = (i: number, sira: number) => ({
|
||||
x: 6 + (i / (noktalar.length - 1)) * (W - 12),
|
||||
y: 8 + ((sira - min) / aralik) * (H - 16),
|
||||
});
|
||||
const path = noktalar
|
||||
.map((n, i) => {
|
||||
const { x, y } = nokta(i, n.sira);
|
||||
return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`;
|
||||
})
|
||||
.join(" ");
|
||||
const son = nokta(noktalar.length - 1, noktalar[noktalar.length - 1].sira);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<h2 className="font-heading text-lg font-bold">Genel değerlendirme</h2>
|
||||
<p className="mt-2 whitespace-pre-line text-sm leading-relaxed text-slate-700">
|
||||
{rapor.genelDegerlendirme}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs text-slate-500">
|
||||
{(["guvenli", "az-riskli", "riskli"] as const).map((r) => (
|
||||
<span key={r} className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={`size-2.5 rounded-full ${
|
||||
r === "guvenli"
|
||||
? "bg-emerald-500"
|
||||
: r === "az-riskli"
|
||||
? "bg-amber-500"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{RISK_ETIKET[r]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ol className="mt-3 space-y-3">
|
||||
{rapor.tercihler.map((t) => {
|
||||
const p = rapor.programlar[t.programId];
|
||||
const risk =
|
||||
(adaySira != null
|
||||
? riskHesapla(p?.efektifSira, adaySira)
|
||||
: null) ?? dilimdenRisk(t.dilim);
|
||||
const stil = RISK_STIL[risk];
|
||||
return (
|
||||
<li
|
||||
key={t.sira}
|
||||
className={`rounded-2xl border border-l-4 border-slate-200 bg-white p-5 ${stil.kenar}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="flex size-8 items-center justify-center rounded-full bg-slate-100 font-heading text-sm font-bold">
|
||||
{t.sira}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold">{p?.isim ?? t.programId}</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{p?.universite} · {p?.il ?? "—"}
|
||||
{p?.unitur ? ` · ${p.unitur}` : ""}
|
||||
{p?.efektifSira != null
|
||||
? ` · taban ~${p.efektifSira.toLocaleString("tr-TR")}.`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className={stil.badge}>
|
||||
<stil.icon className="size-3" aria-hidden />
|
||||
{DILIM_ETIKET[t.dilim]} · {RISK_ETIKET[risk]}
|
||||
</Badge>
|
||||
</div>
|
||||
<dl className="mt-3 grid gap-2 text-sm sm:grid-cols-3">
|
||||
<div className="rounded-lg bg-slate-50 p-3">
|
||||
<dt className="text-xs font-semibold text-slate-500">
|
||||
Neden listede?
|
||||
</dt>
|
||||
<dd className="mt-1 text-slate-700">{t.gerekce}</dd>
|
||||
</div>
|
||||
<div className="rounded-lg bg-slate-50 p-3">
|
||||
<dt className="text-xs font-semibold text-slate-500">
|
||||
Risk notu
|
||||
</dt>
|
||||
<dd className="mt-1 text-slate-700">{t.riskNotu}</dd>
|
||||
</div>
|
||||
<div className="rounded-lg bg-slate-50 p-3">
|
||||
<dt className="text-xs font-semibold text-slate-500">
|
||||
4 yıllık trend
|
||||
</dt>
|
||||
<dd className="mt-1 text-slate-700">{t.trendOzeti}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="h-10 w-[150px]"
|
||||
role="img"
|
||||
aria-label={`Taban sıralaması ${noktalar[0].yil}: ${noktalar[0].sira.toLocaleString("tr-TR")}, ${noktalar[noktalar.length - 1].yil}: ${noktalar[noktalar.length - 1].sira.toLocaleString("tr-TR")}`}
|
||||
>
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="oklch(0.623 0.188 259.8)"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx={son.x} cy={son.y} r={3} fill="oklch(0.623 0.188 259.8)" />
|
||||
</svg>
|
||||
<span className="text-xs text-slate-500">
|
||||
{noktalar[0].yil}: ~{noktalar[0].sira.toLocaleString("tr-TR")}
|
||||
<br />
|
||||
{noktalar[noktalar.length - 1].yil}: ~
|
||||
{noktalar[noktalar.length - 1].sira.toLocaleString("tr-TR")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RaporListesi({
|
||||
rapor,
|
||||
adaySira,
|
||||
kilitliBaslangic,
|
||||
kilitOverlay,
|
||||
acikSira,
|
||||
onHaritadaGor,
|
||||
}: {
|
||||
rapor: RaporSonuc;
|
||||
adaySira?: number;
|
||||
kilitliBaslangic?: number;
|
||||
kilitOverlay?: React.ReactNode;
|
||||
/** Dışarıdan (haritadan) açtırılan satır — değişince satır açılıp kaydırılır */
|
||||
acikSira?: number | null;
|
||||
/** Detaydaki "Haritada gör" — il DB biçiminde */
|
||||
onHaritadaGor?: (il: string | null, sira: number) => void;
|
||||
}) {
|
||||
const [acik, setAcik] = useState<Set<number>>(new Set());
|
||||
const satirRefs = useRef(new Map<number, HTMLLIElement>());
|
||||
|
||||
// Haritadan gelen seçim: satırı aç ve görünür alana kaydır
|
||||
useEffect(() => {
|
||||
if (acikSira == null) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAcik((mevcut) => new Set(mevcut).add(acikSira));
|
||||
satirRefs.current
|
||||
.get(acikSira)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, [acikSira]);
|
||||
|
||||
function tiklandi(sira: number) {
|
||||
setAcik((mevcut) => {
|
||||
const yeni = new Set(mevcut);
|
||||
if (yeni.has(sira)) yeni.delete(sira);
|
||||
else yeni.add(sira);
|
||||
return yeni;
|
||||
});
|
||||
}
|
||||
|
||||
const kilitIdx = kilitliBaslangic ?? rapor.tercihler.length;
|
||||
const acikTercihler = rapor.tercihler.slice(0, kilitIdx);
|
||||
// Kilitli alan: tamamını basmaya gerek yok, blur duvarı kısa tutulur
|
||||
const kilitliTercihler = rapor.tercihler.slice(kilitIdx, kilitIdx + 5);
|
||||
|
||||
const satir = (
|
||||
t: RaporSonuc["tercihler"][number],
|
||||
secenekler?: { blurPx?: number },
|
||||
) => {
|
||||
const p = rapor.programlar[t.programId];
|
||||
const risk =
|
||||
(adaySira != null ? riskHesapla(p?.efektifSira, adaySira) : null) ??
|
||||
dilimdenRisk(t.dilim);
|
||||
const stil = RISK_STIL[risk];
|
||||
const trend = trendYonu(p?.siraGecmisi);
|
||||
const trendG = trend ? TREND_GORUNUM[trend] : null;
|
||||
const acikMi = acik.has(t.sira) && !secenekler?.blurPx;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={t.sira}
|
||||
ref={(el) => {
|
||||
if (el) satirRefs.current.set(t.sira, el);
|
||||
else satirRefs.current.delete(t.sira);
|
||||
}}
|
||||
style={
|
||||
secenekler?.blurPx
|
||||
? { filter: `blur(${secenekler.blurPx}px)` }
|
||||
: undefined
|
||||
}
|
||||
className={`rounded-xl border border-l-4 border-slate-200 bg-white ${stil.kenar}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => tiklandi(t.sira)}
|
||||
aria-expanded={acikMi}
|
||||
className="flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left"
|
||||
>
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-slate-100 font-heading text-xs font-bold">
|
||||
{t.sira}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold">
|
||||
{p?.isim ?? t.programId}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-slate-500">
|
||||
{p?.universite}
|
||||
{p?.il ? ` · ${p.il.toLocaleLowerCase("tr-TR")}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden shrink-0 text-xs tabular-nums text-slate-500 sm:block">
|
||||
{p?.efektifSira != null
|
||||
? `~${p.efektifSira.toLocaleString("tr-TR")}.`
|
||||
: ""}
|
||||
</span>
|
||||
{trendG ? (
|
||||
<trendG.icon
|
||||
className={`size-4 shrink-0 ${trendG.renk}`}
|
||||
aria-label={trendG.etiket}
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
className={`size-2.5 shrink-0 rounded-full ${stil.nokta}`}
|
||||
aria-label={`${DILIM_ETIKET[t.dilim]} · ${RISK_ETIKET[risk]}`}
|
||||
/>
|
||||
<ChevronDown
|
||||
className={`size-4 shrink-0 text-slate-400 transition-transform duration-200 ${acikMi ? "rotate-180" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
|
||||
{acikMi ? (
|
||||
<div className="border-t border-slate-100 px-4 py-3">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<stil.icon className="size-3.5" aria-hidden />
|
||||
<span className="font-medium">
|
||||
{DILIM_ETIKET[t.dilim]} · {RISK_ETIKET[risk]}
|
||||
</span>
|
||||
{p?.unitur ? <span>· {p.unitur}</span> : null}
|
||||
{p?.efektifSira != null ? (
|
||||
<span className="sm:hidden">
|
||||
· taban ~{p.efektifSira.toLocaleString("tr-TR")}.
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<dl className="grid gap-2 text-sm sm:grid-cols-3">
|
||||
<div className="rounded-lg bg-slate-50 p-3">
|
||||
<dt className="text-xs font-semibold text-slate-500">
|
||||
Neden listede?
|
||||
</dt>
|
||||
<dd className="mt-1 text-slate-700">{t.gerekce}</dd>
|
||||
</div>
|
||||
<div className="rounded-lg bg-slate-50 p-3">
|
||||
<dt className="text-xs font-semibold text-slate-500">
|
||||
Risk notu
|
||||
</dt>
|
||||
<dd className="mt-1 text-slate-700">{t.riskNotu}</dd>
|
||||
</div>
|
||||
<div className="rounded-lg bg-slate-50 p-3">
|
||||
<dt className="text-xs font-semibold text-slate-500">
|
||||
4 yıllık trend
|
||||
</dt>
|
||||
<dd className="mt-1 text-slate-700">
|
||||
{t.trendOzeti}
|
||||
{p?.siraGecmisi ? (
|
||||
<TrendCizgisi gecmis={p.siraGecmisi} />
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{onHaritadaGor && p?.il ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onHaritadaGor(p.il, t.sira)}
|
||||
className="mt-3 inline-flex cursor-pointer items-center gap-1.5 text-xs font-medium text-primary transition-colors duration-200 hover:text-primary/80"
|
||||
>
|
||||
<MapPin className="size-3.5" aria-hidden />
|
||||
Haritada gör
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">
|
||||
Satıra dokun; gerekçe, risk notu ve 4 yıllık trend açılsın.
|
||||
</p>
|
||||
|
||||
<ol className="mt-2 space-y-2">{acikTercihler.map((t) => satir(t))}</ol>
|
||||
|
||||
{kilitliTercihler.length > 0 ? (
|
||||
<div className="relative mt-2">
|
||||
<ol
|
||||
className="pointer-events-none max-h-[24rem] select-none space-y-2 overflow-hidden"
|
||||
aria-hidden
|
||||
>
|
||||
{kilitliTercihler.map((t, j) =>
|
||||
satir(t, { blurPx: Math.min(3 + j * 2, 12) }),
|
||||
)}
|
||||
</ol>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-gradient-to-b from-slate-50/20 via-slate-50/75 to-slate-50 px-4">
|
||||
{kilitOverlay}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,12 @@
|
||||
// tamamlanınca onTamamla(secimler) çağırır.
|
||||
|
||||
import { useState } from "react";
|
||||
import { ArrowLeft, ArrowRight, Sparkles } from "lucide-react";
|
||||
import { ArrowLeft, ArrowRight, Sparkles, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cities } from "turkey-map-react/lib/data";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IlSecimHaritasi } from "@/components/il-secim-haritasi";
|
||||
import { normalizeIlAdi } from "@/lib/harita";
|
||||
import type { SihirbazFacetleri } from "@/lib/db";
|
||||
import {
|
||||
ONCELIKLER,
|
||||
@@ -41,6 +44,9 @@ export function Cip({
|
||||
);
|
||||
}
|
||||
|
||||
// Haritada karşılığı olan iller (KKTC/yurtdışı kampüsler çip olarak kalır)
|
||||
const HARITA_ILLERI = new Set(cities.map((c) => normalizeIlAdi(c.name)));
|
||||
|
||||
export function SihirbazAdimlar({
|
||||
facetler,
|
||||
baslangicSecimler,
|
||||
@@ -128,20 +134,57 @@ export function SihirbazAdimlar({
|
||||
Nerede okumak istersin?
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
İstersen boş bırak (fark etmez). En fazla 5 il.
|
||||
Haritadan dokunarak seç (en fazla 5 il). İstersen boş bırak, fark
|
||||
etmez.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{facetler.iller.map((i) => (
|
||||
<Cip
|
||||
key={i.il}
|
||||
secili={iller.includes(i.il)}
|
||||
onClick={() => listeDegistir(iller, setIller, i.il, 5)}
|
||||
>
|
||||
{i.il}
|
||||
<span className="ml-1.5 text-xs opacity-70">{i.adet}</span>
|
||||
</Cip>
|
||||
))}
|
||||
<div className="mt-4">
|
||||
<IlSecimHaritasi
|
||||
iller={facetler.iller.filter((i) => HARITA_ILLERI.has(i.il))}
|
||||
secili={iller}
|
||||
onToggle={(il) => listeDegistir(iller, setIller, il, 5)}
|
||||
/>
|
||||
</div>
|
||||
{iller.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-semibold text-slate-400">
|
||||
Seçtiklerin:
|
||||
</span>
|
||||
{iller.map((il) => (
|
||||
<button
|
||||
key={il}
|
||||
type="button"
|
||||
onClick={() => listeDegistir(iller, setIller, il)}
|
||||
className="inline-flex cursor-pointer items-center gap-1 rounded-full bg-orange-500 px-3 py-1.5 text-xs font-medium text-white transition-colors duration-200 hover:bg-orange-600"
|
||||
>
|
||||
{il.toLocaleLowerCase("tr-TR")}
|
||||
<X className="size-3" aria-hidden />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{facetler.iller.some((i) => !HARITA_ILLERI.has(i.il)) ? (
|
||||
<div className="mt-3">
|
||||
<p className="mb-2 text-xs font-semibold tracking-wide text-slate-400 uppercase">
|
||||
Harita dışı (KKTC vb.)
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{facetler.iller
|
||||
.filter((i) => !HARITA_ILLERI.has(i.il))
|
||||
.map((i) => (
|
||||
<Cip
|
||||
key={i.il}
|
||||
secili={iller.includes(i.il)}
|
||||
onClick={() => listeDegistir(iller, setIller, i.il, 5)}
|
||||
>
|
||||
{i.il.toLocaleLowerCase("tr-TR")}
|
||||
<span className="ml-1.5 text-xs opacity-70">
|
||||
{i.adet}
|
||||
</span>
|
||||
</Cip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
|
||||
417
src/components/tercih-haritasi.tsx
Normal file
417
src/components/tercih-haritasi.tsx
Normal file
@@ -0,0 +1,417 @@
|
||||
"use client";
|
||||
|
||||
// Sonuç sayfasının kahraman öğesi: 24 tercihin Türkiye haritası üzerinde
|
||||
// risk renkli pinlerle gösterimi. Ülke seviyesinde il başına tek toplu pin;
|
||||
// ile tıklayınca viewBox o ilin sınırlarına animasyonla akar, il büyür ve
|
||||
// üniversite pinleri gerçek kampüs konumlarında adlarıyla görünür.
|
||||
// Kilitli (paketsiz) tercihler il merkezinde adsız "?" pini olarak çizilir —
|
||||
// üniversite adı client'a hiç inmediği için paywall haritadan delinemez.
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ArrowLeft, Lock } from "lucide-react";
|
||||
import { cities } from "turkey-map-react/lib/data";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
HARITA_GENISLIK,
|
||||
HARITA_YUKSEKLIK,
|
||||
normalizeIlAdi,
|
||||
} from "@/lib/harita";
|
||||
import type { RiskSeviyesi } from "@/lib/risk";
|
||||
|
||||
export type HaritaPin = {
|
||||
/** Üniversite bazlı benzersiz anahtar (kilitlilerde sentetik) */
|
||||
id: string;
|
||||
/** Görünen ad — kilitli pinlerde boş bırakılır */
|
||||
universite: string;
|
||||
/** DB biçiminde il adı (büyük harf); harita dışıysa null */
|
||||
il: string | null;
|
||||
x: number;
|
||||
y: number;
|
||||
/** Bu üniversitedeki tercihlerin liste sıraları (1-24) */
|
||||
tercihSiralari: number[];
|
||||
risk: RiskSeviyesi;
|
||||
kilitli?: boolean;
|
||||
};
|
||||
|
||||
const RISK_RENK: Record<RiskSeviyesi, string> = {
|
||||
guvenli: "oklch(0.696 0.17 162.5)", // emerald-500
|
||||
"az-riskli": "oklch(0.769 0.188 70.1)", // amber-500
|
||||
riskli: "oklch(0.637 0.237 25.3)", // red-500
|
||||
};
|
||||
|
||||
// SVG uzayı 0 0 1050 585 ama ülke çizimleri y≈144'te başlıyor; üstteki ölü
|
||||
// bandı kırpıyoruz ki harita sayfada gereksiz boşluk bırakmasın.
|
||||
const TAM_GORUNUM = {
|
||||
x: 0,
|
||||
y: 138,
|
||||
w: HARITA_GENISLIK,
|
||||
h: HARITA_YUKSEKLIK - 138,
|
||||
};
|
||||
|
||||
type ViewBox = typeof TAM_GORUNUM;
|
||||
|
||||
/** "ORTA DOĞU TEKNİK ÜNİVERSİTESİ" → "Orta Doğu Teknik" */
|
||||
function kisaUniAdi(ad: string): string {
|
||||
const kisa = ad
|
||||
.replace(/\s+(ÜNİVERSİTESİ|ENSTİTÜSÜ|MESLEK YÜKSEKOKULU)$/i, "")
|
||||
.trim();
|
||||
return kisa
|
||||
.toLocaleLowerCase("tr-TR")
|
||||
.split(" ")
|
||||
.map((k) =>
|
||||
k.length > 2 || k === "29"
|
||||
? k.charAt(0).toLocaleUpperCase("tr-TR") + k.slice(1)
|
||||
: k,
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/** İldeki pinlerin çoğunluk riski (eşitlikte güvenli taraf). */
|
||||
function baskinRisk(pinler: HaritaPin[]): RiskSeviyesi {
|
||||
const sayac: Record<RiskSeviyesi, number> = {
|
||||
guvenli: 0,
|
||||
"az-riskli": 0,
|
||||
riskli: 0,
|
||||
};
|
||||
for (const p of pinler) sayac[p.risk] += p.tercihSiralari.length;
|
||||
return (["guvenli", "az-riskli", "riskli"] as const).reduce((a, b) =>
|
||||
sayac[b] > sayac[a] ? b : a,
|
||||
);
|
||||
}
|
||||
|
||||
/** Aynı noktaya düşen pinleri küçük bir halka üzerinde ayrıştırır. */
|
||||
function cakismaAc(pinler: HaritaPin[]): HaritaPin[] {
|
||||
const gruplar = new Map<string, HaritaPin[]>();
|
||||
for (const p of pinler) {
|
||||
const key = `${Math.round(p.x)}:${Math.round(p.y)}`;
|
||||
const grup = gruplar.get(key);
|
||||
if (grup) grup.push(p);
|
||||
else gruplar.set(key, [p]);
|
||||
}
|
||||
const sonuc: HaritaPin[] = [];
|
||||
for (const grup of gruplar.values()) {
|
||||
if (grup.length === 1) {
|
||||
sonuc.push(grup[0]);
|
||||
continue;
|
||||
}
|
||||
grup.forEach((p, i) => {
|
||||
const aci = (2 * Math.PI * i) / grup.length;
|
||||
sonuc.push({ ...p, x: p.x + Math.cos(aci) * 5, y: p.y + Math.sin(aci) * 5 });
|
||||
});
|
||||
}
|
||||
return sonuc;
|
||||
}
|
||||
|
||||
export function TercihHaritasi({
|
||||
pinler,
|
||||
seciliIl,
|
||||
onSeciliIlDegisti,
|
||||
onTercihSec,
|
||||
}: {
|
||||
pinler: HaritaPin[];
|
||||
/** Zoom'lanacak il (DB biçimi) — liste tarafından da kontrol edilebilir */
|
||||
seciliIl: string | null;
|
||||
onSeciliIlDegisti: (il: string | null) => void;
|
||||
/** Alt şeritte bir tercihe dokunulunca (liste satırına in) */
|
||||
onTercihSec?: (sira: number) => void;
|
||||
}) {
|
||||
const pathRefs = useRef(new Map<string, SVGPathElement>());
|
||||
const [vb, setVb] = useState<ViewBox>(TAM_GORUNUM);
|
||||
const vbRef = useRef<ViewBox>(TAM_GORUNUM);
|
||||
const animRef = useRef(0);
|
||||
|
||||
const animateTo = useCallback((hedef: ViewBox) => {
|
||||
cancelAnimationFrame(animRef.current);
|
||||
const kaynak = { ...vbRef.current };
|
||||
const t0 = performance.now();
|
||||
const SURE = 380;
|
||||
const ease = (t: number) => 1 - Math.pow(1 - t, 3);
|
||||
const adim = (now: number) => {
|
||||
const t = Math.min(1, (now - t0) / SURE);
|
||||
const k = ease(t);
|
||||
const sonraki = {
|
||||
x: kaynak.x + (hedef.x - kaynak.x) * k,
|
||||
y: kaynak.y + (hedef.y - kaynak.y) * k,
|
||||
w: kaynak.w + (hedef.w - kaynak.w) * k,
|
||||
h: kaynak.h + (hedef.h - kaynak.h) * k,
|
||||
};
|
||||
vbRef.current = sonraki;
|
||||
setVb(sonraki);
|
||||
if (t < 1) animRef.current = requestAnimationFrame(adim);
|
||||
};
|
||||
animRef.current = requestAnimationFrame(adim);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => cancelAnimationFrame(animRef.current), []);
|
||||
|
||||
// seciliIl değişince ilgili ilin bbox'ına (veya tam görünüme) ak
|
||||
useEffect(() => {
|
||||
if (!seciliIl) {
|
||||
animateTo(TAM_GORUNUM);
|
||||
return;
|
||||
}
|
||||
const path = pathRefs.current.get(seciliIl);
|
||||
if (!path) return;
|
||||
const b = path.getBBox();
|
||||
const pad = Math.max(b.width, b.height) * 0.22;
|
||||
let x = b.x - pad;
|
||||
let y = b.y - pad;
|
||||
let w = b.width + pad * 2;
|
||||
let h = b.height + pad * 2;
|
||||
// viewBox oranını koru ki il yamulmadan büyüsün ve harita yüksekliği
|
||||
// zoom'da değişmesin (tam görünümle aynı oran)
|
||||
const oran = TAM_GORUNUM.w / TAM_GORUNUM.h;
|
||||
if (w / h > oran) {
|
||||
const yeniH = w / oran;
|
||||
y -= (yeniH - h) / 2;
|
||||
h = yeniH;
|
||||
} else {
|
||||
const yeniW = h * oran;
|
||||
x -= (yeniW - w) / 2;
|
||||
w = yeniW;
|
||||
}
|
||||
animateTo({ x, y, w, h });
|
||||
}, [seciliIl, animateTo]);
|
||||
|
||||
const ilPinleri = useMemo(() => {
|
||||
const gruplar = new Map<string, HaritaPin[]>();
|
||||
for (const p of pinler) {
|
||||
if (!p.il) continue;
|
||||
const grup = gruplar.get(p.il);
|
||||
if (grup) grup.push(p);
|
||||
else gruplar.set(p.il, [p]);
|
||||
}
|
||||
return gruplar;
|
||||
}, [pinler]);
|
||||
|
||||
// Ekran boyutu sabit kalsın diye SVG içi ölçüler zoom oranıyla çarpılır
|
||||
const sf = vb.w / HARITA_GENISLIK;
|
||||
const zoomda = Boolean(seciliIl);
|
||||
const seciliPinler = seciliIl ? (ilPinleri.get(seciliIl) ?? []) : [];
|
||||
const acikPinler = cakismaAc(seciliPinler);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative">
|
||||
<svg
|
||||
viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`}
|
||||
className="h-auto w-full"
|
||||
role="img"
|
||||
aria-label="Tercih listesinin Türkiye haritasındaki dağılımı"
|
||||
>
|
||||
{/* İl zeminleri */}
|
||||
{cities.map((city) => {
|
||||
const key = normalizeIlAdi(city.name);
|
||||
const iceriyor = ilPinleri.has(key);
|
||||
const secili = key === seciliIl;
|
||||
return (
|
||||
<path
|
||||
key={city.id}
|
||||
ref={(el) => {
|
||||
if (el) pathRefs.current.set(key, el);
|
||||
else pathRefs.current.delete(key);
|
||||
}}
|
||||
d={city.path}
|
||||
className={
|
||||
iceriyor
|
||||
? "cursor-pointer transition-opacity duration-200 hover:opacity-80"
|
||||
: undefined
|
||||
}
|
||||
fill={
|
||||
secili
|
||||
? "oklch(0.97 0.014 254.6)" // blue-50: seçili zemin
|
||||
: iceriyor
|
||||
? "oklch(0.882 0.059 254.1)" // blue-200
|
||||
: "oklch(0.929 0.013 255.5)" // slate-200
|
||||
}
|
||||
stroke="white"
|
||||
strokeWidth={secili ? 1.6 * sf : 1 * sf}
|
||||
onClick={() => {
|
||||
if (secili) onSeciliIlDegisti(null);
|
||||
else if (iceriyor) onSeciliIlDegisti(key);
|
||||
}}
|
||||
>
|
||||
<title>
|
||||
{iceriyor
|
||||
? `${city.name}: ${(ilPinleri.get(key) ?? []).reduce(
|
||||
(a, p) => a + p.tercihSiralari.length,
|
||||
0,
|
||||
)} tercih — yakınlaşmak için tıkla`
|
||||
: city.name}
|
||||
</title>
|
||||
</path>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Ülke seviyesi: il başına toplu pin */}
|
||||
{!zoomda
|
||||
? [...ilPinleri.entries()].map(([il, iller]) => {
|
||||
const adet = iller.reduce(
|
||||
(a, p) => a + p.tercihSiralari.length,
|
||||
0,
|
||||
);
|
||||
const cx =
|
||||
iller.reduce((a, p) => a + p.x, 0) / iller.length;
|
||||
const cy =
|
||||
iller.reduce((a, p) => a + p.y, 0) / iller.length;
|
||||
const renk = RISK_RENK[baskinRisk(iller)];
|
||||
return (
|
||||
<g
|
||||
key={il}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onSeciliIlDegisti(il)}
|
||||
role="button"
|
||||
aria-label={`${il.toLocaleLowerCase("tr-TR")}: ${adet} tercih`}
|
||||
>
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={11}
|
||||
fill={renk}
|
||||
stroke="white"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={11}
|
||||
fontWeight={700}
|
||||
fill="white"
|
||||
className="pointer-events-none select-none"
|
||||
>
|
||||
{adet}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
|
||||
{/* İl seviyesi: üniversite pinleri */}
|
||||
{zoomda
|
||||
? acikPinler.map((p) => {
|
||||
const r = (p.kilitli ? 7 : 8) * sf;
|
||||
const etiket = p.kilitli ? null : kisaUniAdi(p.universite);
|
||||
return (
|
||||
<g
|
||||
key={p.id}
|
||||
className={onTercihSec && !p.kilitli ? "cursor-pointer" : undefined}
|
||||
onClick={
|
||||
onTercihSec && !p.kilitli
|
||||
? () => onTercihSec(p.tercihSiralari[0])
|
||||
: undefined
|
||||
}
|
||||
role={p.kilitli ? undefined : "button"}
|
||||
aria-label={
|
||||
p.kilitli
|
||||
? "Kilitli tercih"
|
||||
: `${etiket}: ${p.tercihSiralari.length} tercih`
|
||||
}
|
||||
>
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={r}
|
||||
fill={p.kilitli ? "oklch(0.704 0.04 256.8)" : RISK_RENK[p.risk]}
|
||||
stroke="white"
|
||||
strokeWidth={2 * sf}
|
||||
strokeDasharray={p.kilitli ? `${3 * sf} ${2.5 * sf}` : undefined}
|
||||
/>
|
||||
<text
|
||||
x={p.x}
|
||||
y={p.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={9 * sf}
|
||||
fontWeight={700}
|
||||
fill="white"
|
||||
className="pointer-events-none select-none"
|
||||
>
|
||||
{p.kilitli ? "?" : p.tercihSiralari.length}
|
||||
</text>
|
||||
{etiket ? (
|
||||
<text
|
||||
x={p.x}
|
||||
y={p.y - r - 4 * sf}
|
||||
textAnchor="middle"
|
||||
fontSize={10.5 * sf}
|
||||
fontWeight={600}
|
||||
fill="oklch(0.279 0.041 260.0)" // slate-800
|
||||
stroke="white"
|
||||
strokeWidth={3 * sf}
|
||||
paintOrder="stroke"
|
||||
className="pointer-events-none select-none"
|
||||
>
|
||||
{etiket}
|
||||
</text>
|
||||
) : null}
|
||||
</g>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</svg>
|
||||
|
||||
{zoomda ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onSeciliIlDegisti(null)}
|
||||
className="absolute left-3 top-3 cursor-pointer rounded-full bg-white/90 backdrop-blur"
|
||||
>
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
Türkiye
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Seçili ilin tercih şeridi (yalnızca zoom'dayken) */}
|
||||
{zoomda && seciliPinler.length > 0 ? (
|
||||
<div className="mt-2">
|
||||
<p className="px-1 pb-1 text-xs font-semibold text-slate-500">
|
||||
{seciliIl!.toLocaleLowerCase("tr-TR")} tercihlerin
|
||||
</p>
|
||||
<ul className="flex gap-2 overflow-x-auto pb-1">
|
||||
{seciliPinler
|
||||
.flatMap((p) =>
|
||||
p.tercihSiralari.map((sira) => ({ pin: p, sira })),
|
||||
)
|
||||
.sort((a, b) => a.sira - b.sira)
|
||||
.map(({ pin, sira }) => (
|
||||
<li key={sira} className="shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTercihSec ? () => onTercihSec(sira) : undefined}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-full border border-slate-200 bg-white py-1 pl-1.5 pr-3 text-xs transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
|
||||
>
|
||||
<span
|
||||
className="flex size-5 items-center justify-center rounded-full font-heading text-[10px] font-bold text-white"
|
||||
style={{ backgroundColor: RISK_RENK[pin.risk] }}
|
||||
>
|
||||
{sira}
|
||||
</span>
|
||||
{pin.kilitli ? (
|
||||
<span className="inline-flex items-center gap-1 text-slate-400">
|
||||
<Lock className="size-3" aria-hidden />
|
||||
Kilitli tercih
|
||||
</span>
|
||||
) : (
|
||||
<span className="max-w-40 truncate font-medium text-slate-700">
|
||||
{kisaUniAdi(pin.universite)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
src/components/ui/avatar.tsx
Normal file
112
src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
67
src/components/ui/chat-container.tsx
Normal file
67
src/components/ui/chat-container.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { StickToBottom } from "use-stick-to-bottom"
|
||||
|
||||
export type ChatContainerRootProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export type ChatContainerContentProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export type ChatContainerScrollAnchorProps = {
|
||||
className?: string
|
||||
ref?: React.RefObject<HTMLDivElement>
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
function ChatContainerRoot({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ChatContainerRootProps) {
|
||||
return (
|
||||
<StickToBottom
|
||||
className={cn("flex overflow-y-auto", className)}
|
||||
resize="smooth"
|
||||
initial="instant"
|
||||
role="log"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StickToBottom>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatContainerContent({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ChatContainerContentProps) {
|
||||
return (
|
||||
<StickToBottom.Content
|
||||
className={cn("flex w-full flex-col", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StickToBottom.Content>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatContainerScrollAnchor({
|
||||
className,
|
||||
...props
|
||||
}: ChatContainerScrollAnchorProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("h-px w-full shrink-0 scroll-mt-4", className)}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { ChatContainerRoot, ChatContainerContent, ChatContainerScrollAnchor }
|
||||
94
src/components/ui/code-block.tsx
Normal file
94
src/components/ui/code-block.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { codeToHtml } from "shiki"
|
||||
|
||||
export type CodeBlockProps = {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
} & React.HTMLProps<HTMLDivElement>
|
||||
|
||||
function CodeBlock({ children, className, ...props }: CodeBlockProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"not-prose flex w-full flex-col overflow-clip border",
|
||||
"border-border bg-card text-card-foreground rounded-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type CodeBlockCodeProps = {
|
||||
code: string
|
||||
language?: string
|
||||
theme?: string
|
||||
className?: string
|
||||
} & React.HTMLProps<HTMLDivElement>
|
||||
|
||||
function CodeBlockCode({
|
||||
code,
|
||||
language = "tsx",
|
||||
theme = "github-light",
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCodeProps) {
|
||||
const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function highlight() {
|
||||
if (!code) {
|
||||
setHighlightedHtml("<pre><code></code></pre>")
|
||||
return
|
||||
}
|
||||
|
||||
const html = await codeToHtml(code, { lang: language, theme })
|
||||
setHighlightedHtml(html)
|
||||
}
|
||||
highlight()
|
||||
}, [code, language, theme])
|
||||
|
||||
const classNames = cn(
|
||||
"w-full overflow-x-auto text-[13px] [&>pre]:px-4 [&>pre]:py-4",
|
||||
className
|
||||
)
|
||||
|
||||
// SSR fallback: render plain code if not hydrated yet
|
||||
return highlightedHtml ? (
|
||||
<div
|
||||
className={classNames}
|
||||
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<div className={classNames} {...props}>
|
||||
<pre>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type CodeBlockGroupProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
function CodeBlockGroup({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockGroupProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { CodeBlockGroup, CodeBlockCode, CodeBlock }
|
||||
499
src/components/ui/loader.tsx
Normal file
499
src/components/ui/loader.tsx
Normal file
@@ -0,0 +1,499 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import React from "react"
|
||||
|
||||
export interface LoaderProps {
|
||||
variant?:
|
||||
| "circular"
|
||||
| "classic"
|
||||
| "pulse"
|
||||
| "pulse-dot"
|
||||
| "dots"
|
||||
| "typing"
|
||||
| "wave"
|
||||
| "bars"
|
||||
| "terminal"
|
||||
| "text-blink"
|
||||
| "text-shimmer"
|
||||
| "loading-dots"
|
||||
size?: "sm" | "md" | "lg"
|
||||
text?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CircularLoader({
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
className?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const sizeClasses = {
|
||||
sm: "size-4",
|
||||
md: "size-5",
|
||||
lg: "size-6",
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-primary animate-spin rounded-full border-2 border-t-transparent",
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ClassicLoader({
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
className?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const sizeClasses = {
|
||||
sm: "size-4",
|
||||
md: "size-5",
|
||||
lg: "size-6",
|
||||
}
|
||||
|
||||
const barSizes = {
|
||||
sm: { height: "6px", width: "1.5px" },
|
||||
md: { height: "8px", width: "2px" },
|
||||
lg: { height: "10px", width: "2.5px" },
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("relative", sizeClasses[size], className)}>
|
||||
<div className="absolute h-full w-full">
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-primary absolute animate-[spinner-fade_1.2s_linear_infinite] rounded-full"
|
||||
style={{
|
||||
top: "0",
|
||||
left: "50%",
|
||||
marginLeft:
|
||||
size === "sm" ? "-0.75px" : size === "lg" ? "-1.25px" : "-1px",
|
||||
transformOrigin: `${size === "sm" ? "0.75px" : size === "lg" ? "1.25px" : "1px"} ${size === "sm" ? "10px" : size === "lg" ? "14px" : "12px"}`,
|
||||
transform: `rotate(${i * 30}deg)`,
|
||||
opacity: 0,
|
||||
animationDelay: `${i * 0.1}s`,
|
||||
height: barSizes[size].height,
|
||||
width: barSizes[size].width,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PulseLoader({
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
className?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const sizeClasses = {
|
||||
sm: "size-4",
|
||||
md: "size-5",
|
||||
lg: "size-6",
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("relative", sizeClasses[size], className)}>
|
||||
<div className="border-primary absolute inset-0 animate-[thin-pulse_1.5s_ease-in-out_infinite] rounded-full border-2" />
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PulseDotLoader({
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
className?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const sizeClasses = {
|
||||
sm: "size-1",
|
||||
md: "size-2",
|
||||
lg: "size-3",
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-primary animate-[pulse-dot_1.2s_ease-in-out_infinite] rounded-full",
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DotsLoader({
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
className?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const dotSizes = {
|
||||
sm: "h-1.5 w-1.5",
|
||||
md: "h-2 w-2",
|
||||
lg: "h-2.5 w-2.5",
|
||||
}
|
||||
|
||||
const containerSizes = {
|
||||
sm: "h-4",
|
||||
md: "h-5",
|
||||
lg: "h-6",
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center space-x-1",
|
||||
containerSizes[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"bg-primary animate-[bounce-dots_1.4s_ease-in-out_infinite] rounded-full",
|
||||
dotSizes[size]
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${i * 160}ms`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TypingLoader({
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
className?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const dotSizes = {
|
||||
sm: "h-1 w-1",
|
||||
md: "h-1.5 w-1.5",
|
||||
lg: "h-2 w-2",
|
||||
}
|
||||
|
||||
const containerSizes = {
|
||||
sm: "h-4",
|
||||
md: "h-5",
|
||||
lg: "h-6",
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center space-x-1",
|
||||
containerSizes[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"bg-primary animate-[typing_1s_infinite] rounded-full",
|
||||
dotSizes[size]
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${i * 250}ms`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WaveLoader({
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
className?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const barWidths = {
|
||||
sm: "w-0.5",
|
||||
md: "w-0.5",
|
||||
lg: "w-1",
|
||||
}
|
||||
|
||||
const containerSizes = {
|
||||
sm: "h-4",
|
||||
md: "h-5",
|
||||
lg: "h-6",
|
||||
}
|
||||
|
||||
const heights = {
|
||||
sm: ["6px", "9px", "12px", "9px", "6px"],
|
||||
md: ["8px", "12px", "16px", "12px", "8px"],
|
||||
lg: ["10px", "15px", "20px", "15px", "10px"],
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-0.5",
|
||||
containerSizes[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"bg-primary animate-[wave_1s_ease-in-out_infinite] rounded-full",
|
||||
barWidths[size]
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${i * 100}ms`,
|
||||
height: heights[size][i],
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BarsLoader({
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
className?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const barWidths = {
|
||||
sm: "w-1",
|
||||
md: "w-1.5",
|
||||
lg: "w-2",
|
||||
}
|
||||
|
||||
const containerSizes = {
|
||||
sm: "h-4 gap-1",
|
||||
md: "h-5 gap-1.5",
|
||||
lg: "h-6 gap-2",
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex", containerSizes[size], className)}>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"bg-primary h-full animate-[wave-bars_1.2s_ease-in-out_infinite]",
|
||||
barWidths[size]
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${i * 0.2}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TerminalLoader({
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
className?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const cursorSizes = {
|
||||
sm: "h-3 w-1.5",
|
||||
md: "h-4 w-2",
|
||||
lg: "h-5 w-2.5",
|
||||
}
|
||||
|
||||
const textSizes = {
|
||||
sm: "text-xs",
|
||||
md: "text-sm",
|
||||
lg: "text-base",
|
||||
}
|
||||
|
||||
const containerSizes = {
|
||||
sm: "h-4",
|
||||
md: "h-5",
|
||||
lg: "h-6",
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center space-x-1",
|
||||
containerSizes[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={cn("text-primary font-mono", textSizes[size])}>
|
||||
{">"}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-primary animate-[blink_1s_step-end_infinite]",
|
||||
cursorSizes[size]
|
||||
)}
|
||||
/>
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TextBlinkLoader({
|
||||
text = "Thinking",
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
text?: string
|
||||
className?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const textSizes = {
|
||||
sm: "text-xs",
|
||||
md: "text-sm",
|
||||
lg: "text-base",
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"animate-[text-blink_2s_ease-in-out_infinite] font-medium",
|
||||
textSizes[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TextShimmerLoader({
|
||||
text = "Thinking",
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
text?: string
|
||||
className?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const textSizes = {
|
||||
sm: "text-xs",
|
||||
md: "text-sm",
|
||||
lg: "text-base",
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[linear-gradient(to_right,var(--muted-foreground)_40%,var(--foreground)_60%,var(--muted-foreground)_80%)]",
|
||||
"bg-size-[200%_auto] bg-clip-text font-medium text-transparent",
|
||||
"animate-[shimmer_4s_infinite_linear]",
|
||||
textSizes[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TextDotsLoader({
|
||||
className,
|
||||
text = "Thinking",
|
||||
size = "md",
|
||||
}: {
|
||||
className?: string
|
||||
text?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const textSizes = {
|
||||
sm: "text-xs",
|
||||
md: "text-sm",
|
||||
lg: "text-base",
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("inline-flex items-center", className)}
|
||||
>
|
||||
<span className={cn("text-primary font-medium", textSizes[size])}>
|
||||
{text}
|
||||
</span>
|
||||
<span className="inline-flex">
|
||||
<span className="text-primary animate-[loading-dots_1.4s_infinite_0.2s]">
|
||||
.
|
||||
</span>
|
||||
<span className="text-primary animate-[loading-dots_1.4s_infinite_0.4s]">
|
||||
.
|
||||
</span>
|
||||
<span className="text-primary animate-[loading-dots_1.4s_infinite_0.6s]">
|
||||
.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Loader({
|
||||
variant = "circular",
|
||||
size = "md",
|
||||
text,
|
||||
className,
|
||||
}: LoaderProps) {
|
||||
switch (variant) {
|
||||
case "circular":
|
||||
return <CircularLoader size={size} className={className} />
|
||||
case "classic":
|
||||
return <ClassicLoader size={size} className={className} />
|
||||
case "pulse":
|
||||
return <PulseLoader size={size} className={className} />
|
||||
case "pulse-dot":
|
||||
return <PulseDotLoader size={size} className={className} />
|
||||
case "dots":
|
||||
return <DotsLoader size={size} className={className} />
|
||||
case "typing":
|
||||
return <TypingLoader size={size} className={className} />
|
||||
case "wave":
|
||||
return <WaveLoader size={size} className={className} />
|
||||
case "bars":
|
||||
return <BarsLoader size={size} className={className} />
|
||||
case "terminal":
|
||||
return <TerminalLoader size={size} className={className} />
|
||||
case "text-blink":
|
||||
return <TextBlinkLoader text={text} size={size} className={className} />
|
||||
case "text-shimmer":
|
||||
return <TextShimmerLoader text={text} size={size} className={className} />
|
||||
case "loading-dots":
|
||||
return <TextDotsLoader text={text} size={size} className={className} />
|
||||
default:
|
||||
return <CircularLoader size={size} className={className} />
|
||||
}
|
||||
}
|
||||
|
||||
export { Loader }
|
||||
110
src/components/ui/markdown.tsx
Normal file
110
src/components/ui/markdown.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { marked } from "marked"
|
||||
import { memo, useId, useMemo } from "react"
|
||||
import ReactMarkdown, { Components } from "react-markdown"
|
||||
import remarkBreaks from "remark-breaks"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import { CodeBlock, CodeBlockCode } from "./code-block"
|
||||
|
||||
export type MarkdownProps = {
|
||||
children: string
|
||||
id?: string
|
||||
className?: string
|
||||
components?: Partial<Components>
|
||||
}
|
||||
|
||||
function parseMarkdownIntoBlocks(markdown: string): string[] {
|
||||
const tokens = marked.lexer(markdown)
|
||||
return tokens.map((token) => token.raw)
|
||||
}
|
||||
|
||||
function extractLanguage(className?: string): string {
|
||||
if (!className) return "plaintext"
|
||||
const match = className.match(/language-(\w+)/)
|
||||
return match ? match[1] : "plaintext"
|
||||
}
|
||||
|
||||
const INITIAL_COMPONENTS: Partial<Components> = {
|
||||
code: function CodeComponent({ className, children, ...props }) {
|
||||
const isInline =
|
||||
!props.node?.position?.start.line ||
|
||||
props.node?.position?.start.line === props.node?.position?.end.line
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"bg-primary-foreground rounded-sm px-1 font-mono text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const language = extractLanguage(className)
|
||||
|
||||
return (
|
||||
<CodeBlock className={className}>
|
||||
<CodeBlockCode code={children as string} language={language} />
|
||||
</CodeBlock>
|
||||
)
|
||||
},
|
||||
pre: function PreComponent({ children }) {
|
||||
return <>{children}</>
|
||||
},
|
||||
}
|
||||
|
||||
const MemoizedMarkdownBlock = memo(
|
||||
function MarkdownBlock({
|
||||
content,
|
||||
components = INITIAL_COMPONENTS,
|
||||
}: {
|
||||
content: string
|
||||
components?: Partial<Components>
|
||||
}) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
},
|
||||
function propsAreEqual(prevProps, nextProps) {
|
||||
return prevProps.content === nextProps.content
|
||||
}
|
||||
)
|
||||
|
||||
MemoizedMarkdownBlock.displayName = "MemoizedMarkdownBlock"
|
||||
|
||||
function MarkdownComponent({
|
||||
children,
|
||||
id,
|
||||
className,
|
||||
components = INITIAL_COMPONENTS,
|
||||
}: MarkdownProps) {
|
||||
const generatedId = useId()
|
||||
const blockId = id ?? generatedId
|
||||
const blocks = useMemo(() => parseMarkdownIntoBlocks(children), [children])
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{blocks.map((block, index) => (
|
||||
<MemoizedMarkdownBlock
|
||||
key={`${blockId}-block-${index}`}
|
||||
content={block}
|
||||
components={components}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Markdown = memo(MarkdownComponent)
|
||||
Markdown.displayName = "Markdown"
|
||||
|
||||
export { Markdown }
|
||||
120
src/components/ui/message.tsx
Normal file
120
src/components/ui/message.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Markdown } from "./markdown"
|
||||
|
||||
export type MessageProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
} & React.HTMLProps<HTMLDivElement>
|
||||
|
||||
const Message = ({ children, className, ...props }: MessageProps) => (
|
||||
<div className={cn("flex gap-3", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type MessageAvatarProps = {
|
||||
src: string
|
||||
alt: string
|
||||
fallback?: string
|
||||
delayMs?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
const MessageAvatar = ({
|
||||
src,
|
||||
alt,
|
||||
fallback,
|
||||
delayMs,
|
||||
className,
|
||||
}: MessageAvatarProps) => {
|
||||
return (
|
||||
<Avatar className={cn("h-8 w-8 shrink-0", className)}>
|
||||
<AvatarImage src={src} alt={alt} />
|
||||
{fallback && (
|
||||
<AvatarFallback delayMs={delayMs}>{fallback}</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
|
||||
export type MessageContentProps = {
|
||||
children: React.ReactNode
|
||||
markdown?: boolean
|
||||
className?: string
|
||||
} & React.ComponentProps<typeof Markdown> &
|
||||
React.HTMLProps<HTMLDivElement>
|
||||
|
||||
const MessageContent = ({
|
||||
children,
|
||||
markdown = false,
|
||||
className,
|
||||
...props
|
||||
}: MessageContentProps) => {
|
||||
const classNames = cn(
|
||||
"rounded-lg p-2 text-foreground bg-secondary prose break-words whitespace-normal",
|
||||
className
|
||||
)
|
||||
|
||||
return markdown ? (
|
||||
<Markdown className={classNames} {...props}>
|
||||
{children as string}
|
||||
</Markdown>
|
||||
) : (
|
||||
<div className={classNames} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type MessageActionsProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
} & React.HTMLProps<HTMLDivElement>
|
||||
|
||||
const MessageActions = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageActionsProps) => (
|
||||
<div
|
||||
className={cn("text-muted-foreground flex items-center gap-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type MessageActionProps = {
|
||||
className?: string
|
||||
tooltip: React.ReactNode
|
||||
children: React.ReactNode
|
||||
side?: "top" | "bottom" | "left" | "right"
|
||||
} & React.ComponentProps<typeof Tooltip>
|
||||
|
||||
const MessageAction = ({
|
||||
tooltip,
|
||||
children,
|
||||
className,
|
||||
side = "top",
|
||||
...props
|
||||
}: MessageActionProps) => {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip {...props}>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent side={side} className={className}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export { Message, MessageAvatar, MessageContent, MessageActions, MessageAction }
|
||||
233
src/components/ui/prompt-input.tsx
Normal file
233
src/components/ui/prompt-input.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"use client"
|
||||
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
type PromptInputContextType = {
|
||||
isLoading: boolean
|
||||
value: string
|
||||
setValue: (value: string) => void
|
||||
maxHeight: number | string
|
||||
onSubmit?: () => void
|
||||
disabled?: boolean
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>
|
||||
}
|
||||
|
||||
const PromptInputContext = createContext<PromptInputContextType>({
|
||||
isLoading: false,
|
||||
value: "",
|
||||
setValue: () => {},
|
||||
maxHeight: 240,
|
||||
onSubmit: undefined,
|
||||
disabled: false,
|
||||
textareaRef: React.createRef<HTMLTextAreaElement>(),
|
||||
})
|
||||
|
||||
function usePromptInput() {
|
||||
return useContext(PromptInputContext)
|
||||
}
|
||||
|
||||
export type PromptInputProps = {
|
||||
isLoading?: boolean
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
maxHeight?: number | string
|
||||
onSubmit?: () => void
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
} & React.ComponentProps<"div">
|
||||
|
||||
function PromptInput({
|
||||
className,
|
||||
isLoading = false,
|
||||
maxHeight = 240,
|
||||
value,
|
||||
onValueChange,
|
||||
onSubmit,
|
||||
children,
|
||||
disabled = false,
|
||||
onClick,
|
||||
...props
|
||||
}: PromptInputProps) {
|
||||
const [internalValue, setInternalValue] = useState(value || "")
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
setInternalValue(newValue)
|
||||
onValueChange?.(newValue)
|
||||
}
|
||||
|
||||
const handleClick: React.MouseEventHandler<HTMLDivElement> = (e) => {
|
||||
if (!disabled) textareaRef.current?.focus()
|
||||
onClick?.(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<PromptInputContext.Provider
|
||||
value={{
|
||||
isLoading,
|
||||
value: value ?? internalValue,
|
||||
setValue: onValueChange ?? handleChange,
|
||||
maxHeight,
|
||||
onSubmit,
|
||||
disabled,
|
||||
textareaRef,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"border-input bg-background cursor-text rounded-3xl border p-2 shadow-xs",
|
||||
disabled && "cursor-not-allowed opacity-60",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</PromptInputContext.Provider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export type PromptInputTextareaProps = {
|
||||
disableAutosize?: boolean
|
||||
} & React.ComponentProps<typeof Textarea>
|
||||
|
||||
function PromptInputTextarea({
|
||||
className,
|
||||
onKeyDown,
|
||||
disableAutosize = false,
|
||||
...props
|
||||
}: PromptInputTextareaProps) {
|
||||
const { value, setValue, maxHeight, onSubmit, disabled, textareaRef } =
|
||||
usePromptInput()
|
||||
|
||||
const adjustHeight = (el: HTMLTextAreaElement | null) => {
|
||||
if (!el || disableAutosize) return
|
||||
|
||||
el.style.height = "auto"
|
||||
|
||||
if (typeof maxHeight === "number") {
|
||||
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`
|
||||
} else {
|
||||
el.style.height = `min(${el.scrollHeight}px, ${maxHeight})`
|
||||
}
|
||||
}
|
||||
|
||||
const handleRef = (el: HTMLTextAreaElement | null) => {
|
||||
textareaRef.current = el
|
||||
adjustHeight(el)
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!textareaRef.current || disableAutosize) return
|
||||
|
||||
const el = textareaRef.current
|
||||
el.style.height = "auto"
|
||||
|
||||
if (typeof maxHeight === "number") {
|
||||
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`
|
||||
} else {
|
||||
el.style.height = `min(${el.scrollHeight}px, ${maxHeight})`
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value, maxHeight, disableAutosize])
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
adjustHeight(e.target)
|
||||
setValue(e.target.value)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onSubmit?.()
|
||||
}
|
||||
onKeyDown?.(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
ref={handleRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"text-primary min-h-[44px] w-full resize-none border-none bg-transparent shadow-none outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
className
|
||||
)}
|
||||
rows={1}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type PromptInputActionsProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
function PromptInputActions({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: PromptInputActionsProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type PromptInputActionProps = {
|
||||
className?: string
|
||||
tooltip: React.ReactNode
|
||||
children: React.ReactNode
|
||||
side?: "top" | "bottom" | "left" | "right"
|
||||
} & React.ComponentProps<typeof Tooltip>
|
||||
|
||||
function PromptInputAction({
|
||||
tooltip,
|
||||
children,
|
||||
className,
|
||||
side = "top",
|
||||
...props
|
||||
}: PromptInputActionProps) {
|
||||
const { disabled } = usePromptInput()
|
||||
|
||||
return (
|
||||
<Tooltip {...props}>
|
||||
<TooltipTrigger
|
||||
asChild
|
||||
disabled={disabled}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side} className={className}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
PromptInput,
|
||||
PromptInputTextarea,
|
||||
PromptInputActions,
|
||||
PromptInputAction,
|
||||
}
|
||||
117
src/components/ui/prompt-suggestion.tsx
Normal file
117
src/components/ui/prompt-suggestion.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
"use client"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { VariantProps } from "class-variance-authority"
|
||||
|
||||
export type PromptSuggestionProps = {
|
||||
children: React.ReactNode
|
||||
variant?: VariantProps<typeof buttonVariants>["variant"]
|
||||
size?: VariantProps<typeof buttonVariants>["size"]
|
||||
className?: string
|
||||
highlight?: string
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
|
||||
function PromptSuggestion({
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
className,
|
||||
highlight,
|
||||
...props
|
||||
}: PromptSuggestionProps) {
|
||||
const isHighlightMode = highlight !== undefined && highlight.trim() !== ""
|
||||
const content = typeof children === "string" ? children : ""
|
||||
|
||||
if (!isHighlightMode) {
|
||||
return (
|
||||
<Button
|
||||
variant={variant || "outline"}
|
||||
size={size || "lg"}
|
||||
className={cn("rounded-full", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return (
|
||||
<Button
|
||||
variant={variant || "ghost"}
|
||||
size={size || "sm"}
|
||||
className={cn(
|
||||
"w-full cursor-pointer justify-start rounded-xl py-2",
|
||||
"hover:bg-accent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const trimmedHighlight = highlight.trim()
|
||||
const contentLower = content.toLowerCase()
|
||||
const highlightLower = trimmedHighlight.toLowerCase()
|
||||
const shouldHighlight = contentLower.includes(highlightLower)
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant || "ghost"}
|
||||
size={size || "sm"}
|
||||
className={cn(
|
||||
"w-full cursor-pointer justify-start gap-0 rounded-xl py-2",
|
||||
"hover:bg-accent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{shouldHighlight ? (
|
||||
(() => {
|
||||
const index = contentLower.indexOf(highlightLower)
|
||||
if (index === -1)
|
||||
return (
|
||||
<span className="text-muted-foreground whitespace-pre-wrap">
|
||||
{content}
|
||||
</span>
|
||||
)
|
||||
|
||||
const actualHighlightedText = content.substring(
|
||||
index,
|
||||
index + highlightLower.length
|
||||
)
|
||||
|
||||
const before = content.substring(0, index)
|
||||
const after = content.substring(index + actualHighlightedText.length)
|
||||
|
||||
return (
|
||||
<>
|
||||
{before && (
|
||||
<span className="text-muted-foreground whitespace-pre-wrap">
|
||||
{before}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-primary font-medium whitespace-pre-wrap">
|
||||
{actualHighlightedText}
|
||||
</span>
|
||||
{after && (
|
||||
<span className="text-muted-foreground whitespace-pre-wrap">
|
||||
{after}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<span className="text-muted-foreground whitespace-pre-wrap">
|
||||
{content}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export { PromptSuggestion }
|
||||
42
src/components/ui/scroll-button.tsx
Normal file
42
src/components/ui/scroll-button.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
import { useStickToBottomContext } from "use-stick-to-bottom"
|
||||
|
||||
export type ScrollButtonProps = {
|
||||
className?: string
|
||||
variant?: VariantProps<typeof buttonVariants>["variant"]
|
||||
size?: VariantProps<typeof buttonVariants>["size"]
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
|
||||
function ScrollButton({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "sm",
|
||||
...props
|
||||
}: ScrollButtonProps) {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext()
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"h-10 w-10 rounded-full transition-all duration-150 ease-out",
|
||||
!isAtBottom
|
||||
? "translate-y-0 scale-100 opacity-100"
|
||||
: "pointer-events-none translate-y-4 scale-95 opacity-0",
|
||||
className
|
||||
)}
|
||||
onClick={() => scrollToBottom()}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollButton }
|
||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
57
src/components/ui/tooltip.tsx
Normal file
57
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
236
src/components/ui/typing-animation.tsx
Normal file
236
src/components/ui/typing-animation.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type RefAttributes,
|
||||
type RefObject,
|
||||
} from "react"
|
||||
import {
|
||||
motion,
|
||||
useInView,
|
||||
type DOMMotionComponents,
|
||||
type HTMLMotionProps,
|
||||
type MotionProps,
|
||||
} from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const motionElements = {
|
||||
article: motion.article,
|
||||
div: motion.div,
|
||||
h1: motion.h1,
|
||||
h2: motion.h2,
|
||||
h3: motion.h3,
|
||||
h4: motion.h4,
|
||||
h5: motion.h5,
|
||||
h6: motion.h6,
|
||||
li: motion.li,
|
||||
p: motion.p,
|
||||
section: motion.section,
|
||||
span: motion.span,
|
||||
} as const
|
||||
|
||||
type MotionElementType = Extract<
|
||||
keyof DOMMotionComponents,
|
||||
keyof typeof motionElements
|
||||
>
|
||||
type TypingAnimationMotionComponent = ComponentType<
|
||||
Omit<HTMLMotionProps<"span">, "ref"> & RefAttributes<HTMLElement>
|
||||
>
|
||||
|
||||
interface TypingAnimationProps extends Omit<MotionProps, "children"> {
|
||||
children?: string
|
||||
words?: string[]
|
||||
className?: string
|
||||
duration?: number
|
||||
typeSpeed?: number
|
||||
deleteSpeed?: number
|
||||
delay?: number
|
||||
pauseDelay?: number
|
||||
loop?: boolean
|
||||
as?: MotionElementType
|
||||
startOnView?: boolean
|
||||
showCursor?: boolean
|
||||
blinkCursor?: boolean
|
||||
cursorStyle?: "line" | "block" | "underscore"
|
||||
}
|
||||
|
||||
export function TypingAnimation({
|
||||
children,
|
||||
words,
|
||||
className,
|
||||
duration = 100,
|
||||
typeSpeed,
|
||||
deleteSpeed,
|
||||
delay = 0,
|
||||
pauseDelay = 1000,
|
||||
loop = false,
|
||||
as: Component = "span",
|
||||
startOnView = true,
|
||||
showCursor = true,
|
||||
blinkCursor = true,
|
||||
cursorStyle = "line",
|
||||
...props
|
||||
}: TypingAnimationProps) {
|
||||
const MotionComponent = motionElements[
|
||||
Component
|
||||
] as TypingAnimationMotionComponent
|
||||
|
||||
const [displayedText, setDisplayedText] = useState<string>("")
|
||||
const [currentWordIndex, setCurrentWordIndex] = useState(0)
|
||||
const [currentCharIndex, setCurrentCharIndex] = useState(0)
|
||||
const [phase, setPhase] = useState<"typing" | "pause" | "deleting">("typing")
|
||||
const elementRef = useRef<HTMLElement | null>(null)
|
||||
const isInView = useInView(elementRef as RefObject<Element>, {
|
||||
amount: 0.3,
|
||||
once: true,
|
||||
})
|
||||
|
||||
const wordsToAnimate = useMemo(
|
||||
() => words ?? (children ? [children] : []),
|
||||
[words, children]
|
||||
)
|
||||
const hasMultipleWords = wordsToAnimate.length > 1
|
||||
|
||||
const typingSpeed = typeSpeed ?? duration
|
||||
const deletingSpeed = deleteSpeed ?? typingSpeed / 2
|
||||
|
||||
const shouldStart = startOnView ? isInView : true
|
||||
const animationSourceKey = useMemo(
|
||||
() => (words ? words.join("\u0000") : (children ?? "")),
|
||||
[words, children]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayedText("")
|
||||
setCurrentWordIndex(0)
|
||||
setCurrentCharIndex(0)
|
||||
setPhase("typing")
|
||||
}, [animationSourceKey])
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
if (shouldStart && wordsToAnimate.length > 0) {
|
||||
const timeoutDelay =
|
||||
delay > 0 && displayedText === ""
|
||||
? delay
|
||||
: phase === "typing"
|
||||
? typingSpeed
|
||||
: phase === "deleting"
|
||||
? deletingSpeed
|
||||
: pauseDelay
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
const currentWord = wordsToAnimate[currentWordIndex] || ""
|
||||
const graphemes = Array.from(currentWord)
|
||||
|
||||
switch (phase) {
|
||||
case "typing":
|
||||
if (currentCharIndex < graphemes.length) {
|
||||
setDisplayedText(
|
||||
graphemes.slice(0, currentCharIndex + 1).join("")
|
||||
)
|
||||
setCurrentCharIndex(currentCharIndex + 1)
|
||||
} else {
|
||||
if (hasMultipleWords || loop) {
|
||||
const isLastWord =
|
||||
currentWordIndex === wordsToAnimate.length - 1
|
||||
if (!isLastWord || loop) {
|
||||
setPhase("pause")
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case "pause":
|
||||
setPhase("deleting")
|
||||
break
|
||||
|
||||
case "deleting":
|
||||
if (currentCharIndex > 0) {
|
||||
setDisplayedText(
|
||||
graphemes.slice(0, currentCharIndex - 1).join("")
|
||||
)
|
||||
setCurrentCharIndex(currentCharIndex - 1)
|
||||
} else {
|
||||
const nextIndex = (currentWordIndex + 1) % wordsToAnimate.length
|
||||
setCurrentWordIndex(nextIndex)
|
||||
setPhase("typing")
|
||||
}
|
||||
break
|
||||
}
|
||||
}, timeoutDelay)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
shouldStart,
|
||||
phase,
|
||||
currentCharIndex,
|
||||
currentWordIndex,
|
||||
displayedText,
|
||||
wordsToAnimate,
|
||||
hasMultipleWords,
|
||||
loop,
|
||||
typingSpeed,
|
||||
deletingSpeed,
|
||||
pauseDelay,
|
||||
delay,
|
||||
])
|
||||
|
||||
const currentWordGraphemes = Array.from(
|
||||
wordsToAnimate[currentWordIndex] || ""
|
||||
)
|
||||
const isComplete =
|
||||
!loop &&
|
||||
currentWordIndex === wordsToAnimate.length - 1 &&
|
||||
currentCharIndex >= currentWordGraphemes.length &&
|
||||
phase !== "deleting"
|
||||
|
||||
const shouldShowCursor =
|
||||
showCursor &&
|
||||
!isComplete &&
|
||||
(hasMultipleWords || loop || currentCharIndex < currentWordGraphemes.length)
|
||||
|
||||
const getCursorChar = () => {
|
||||
switch (cursorStyle) {
|
||||
case "block":
|
||||
return "▌"
|
||||
case "underscore":
|
||||
return "_"
|
||||
case "line":
|
||||
default:
|
||||
return "|"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
ref={elementRef}
|
||||
className={cn(
|
||||
"leading-20 tracking-[-0.02em]",
|
||||
Component === "span" && "inline-block",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{displayedText}
|
||||
{shouldShowCursor && (
|
||||
<span
|
||||
className={cn("inline-block", blinkCursor && "animate-blink-cursor")}
|
||||
>
|
||||
{getCursorChar()}
|
||||
</span>
|
||||
)}
|
||||
</MotionComponent>
|
||||
)
|
||||
}
|
||||
@@ -2,24 +2,30 @@ import Link from "next/link";
|
||||
import { Coins } from "lucide-react";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ListemButonu } from "@/components/manuel-liste/listem-butonu";
|
||||
import { SignOutButton } from "./sign-out-button";
|
||||
|
||||
export async function UserNav() {
|
||||
const u = await getCurrentUser();
|
||||
|
||||
// Manuel liste localStorage'ta yaşadığı için Listem girişsiz de çalışır
|
||||
if (!u) {
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
className="h-12 cursor-pointer rounded-full bg-slate-900 px-7 text-base font-medium text-white transition-colors duration-200 hover:bg-slate-700"
|
||||
>
|
||||
<Link href="/giris">Giriş yap</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<ListemButonu />
|
||||
<Button
|
||||
asChild
|
||||
className="h-12 cursor-pointer rounded-full bg-slate-900 px-7 text-base font-medium text-white transition-colors duration-200 hover:bg-slate-700"
|
||||
>
|
||||
<Link href="/giris">Giriş yap</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ListemButonu />
|
||||
<Link
|
||||
href="/paket"
|
||||
className="inline-flex h-12 items-center gap-2 whitespace-nowrap rounded-full border border-slate-200 bg-white px-5 text-sm font-semibold text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50"
|
||||
|
||||
Reference in New Issue
Block a user