Update Next.js configuration and package dependencies
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:
bilalgursen
2026-07-26 04:26:52 +03:00
parent 1d0328d8eb
commit 209d203fe8
59 changed files with 6599 additions and 1446 deletions

View 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>
);
}