diff --git a/src/components/ui/acilis-sahnesi.tsx b/src/components/ui/acilis-sahnesi.tsx new file mode 100644 index 0000000..34e91c5 --- /dev/null +++ b/src/components/ui/acilis-sahnesi.tsx @@ -0,0 +1,308 @@ +"use client"; + +// Alt sayfa açılış sahnesi (DESIGN.md §4.19 açılış koreografisi, §6 hareket): +// renkli kareler önce ortada logonun üç karesi gibi belirir, sonra içerik +// gelir. İki mod: +// - Kartlı (akademik-ayrinti.tsx, gösterge kareleri §4.11): `[data-kare]` +// kareler logodan kendi yerine ve boyuna büyür, içleri satır satır +// bulanıklıktan netleşir, ardından `[data-sonra]` bloklar. +// - Kartsız (program-ayrinti.tsx, §4.19 "kart yok"): `[data-kare]` yok, +// `[data-logo]` kabındaki üç `[data-logo-kare]` belirir, kısa durur, sonra +// sönerek kaybolur (scale 0.6 + opacity 0; Bilal, 7 Eki 2026) ve +// `[data-sonra]` bloklar netleşerek gelir. +// "Hareketi azalt"ta logo ve büyüme yok, yalnız 0,25 s solma (§6). + +import { useLayoutEffect, useRef, type ReactNode } from "react"; +import { animate, useReducedMotion } from "motion/react"; + +// Logodaki üç kare (public/kolay-tercih-mark.svg, 180'lik viewBox): solda +// mavi, sağ üstte şeftali, sağ altta turuncu. animasyonlu-mark.tsx ile aynı. +// Marka rengi; iki temada da sabit kalır. +const LOGO = { + sol: { x: 27, y: 56, boyut: 57, renk: "#3b82f6" }, + "sag-ust": { x: 91, y: 29, boyut: 56, renk: "#fdc7a7" }, + "sag-alt": { x: 91, y: 95, boyut: 56, renk: "#ff5a00" }, +} as const; +const LOGO_RX = 13; +const SLOT_SIRASI = ["sol", "sag-ust", "sag-alt"] as const; +type Slot = (typeof SLOT_SIRASI)[number]; + +// Saniye. Kare: belir (logo) → bekle → yerine büyü; içerik kare oturunca. +const BASLANGIC = 0.1; +const SLOT_ADIMI = 0.08; +const KARE_SURE = 1.15; +const KARE_ZAMANLARI = [0, 0.24, 0.52, 1]; +const ICERIK_BASI = 0.95; +const GUCLU_CIKIS = [0.23, 1, 0.32, 1] as const; +const YERLESME = [0.32, 0.72, 0, 1] as const; + +// Kartsız mod: logo karesi belir → dur → sön. Belirme ve duruş kartlı +// modla aynı anlara denk gelir (KARE_SURE·0.24, ·0.52); sönme 0,4 s. +const LOGO_SURE = KARE_SURE * KARE_ZAMANLARI[2] + 0.4; +const LOGO_ZAMANLARI = [ + 0, + (KARE_SURE * KARE_ZAMANLARI[1]) / LOGO_SURE, + (KARE_SURE * KARE_ZAMANLARI[2]) / LOGO_SURE, + 1, +]; + +/** Kartsız modun logo kareleri, belirme sırasıyla (`data-logo-kare` değerleri). */ +export const LOGO_SLOTLARI = SLOT_SIRASI; + +/** + * Kartsız modun logo karesi stili: logodaki yer ve renk, kabın boyuna ölçekli + * (`data-logo` kabı `relative mx-auto h-16 w-16` → 64/180). Kullanım: + * ``. + * Kareler yalnız sahne sırasında görünür; sahne bitince kap sıfır boya iner, + * "hareketi azalt"ta hiç gösterilmez. + */ +export function logoKareStili(slot: Slot, kapBoyu = 64) { + const olcek = kapBoyu / 180; + return { + left: LOGO[slot].x * olcek, + top: LOGO[slot].y * olcek, + width: LOGO[slot].boyut * olcek, + height: LOGO[slot].boyut * olcek, + borderRadius: LOGO_RX * olcek, + backgroundColor: LOGO[slot].renk, + }; +} + +/** + * Çekmece açılınca oynayan sahne. + * + * Sunucu işaretlemesiyle sözleşme (universite-icerik.tsx, AkademikGostergeler): + * `data-sahne` kareleri saran `relative` kap, `data-kare="sol|sag-ust|sag-alt"` + * karenin logodaki yeri (aynı yeri paylaşan kareler üst üste başlar, sonra + * ayrışır), `data-sonra` karelerden sonra gelen bloklar. Kartsız sözleşme: + * `data-logo` kabı (üç `data-logo-kare`, logoKareStili) + `data-sonra` bloklar. + * + * Kareler yalnız transform ile oynar (yerleşim ölçülür, bozulmaz); içerik o + * sırada görünmez olduğu için kare ölçeklenirken yazı ezilmez. + * + * `hazir`: false iken sahne oynamaz (iskelet görünür kalır), true olunca + * oynar — veri bekleyen çekmece için. Varsayılan true (içerik sunucudan + * hazır gelen çekmece). + */ +export function AcilisSahnesi({ + children, + hazir = true, + className = "mt-5", +}: { + children: ReactNode; + hazir?: boolean; + className?: string; +}) { + const kapRef = useRef(null); + const azHareket = useReducedMotion(); + + useLayoutEffect(() => { + const kap = kapRef.current; + // hazir false → true tek geçiş: sahne o geçişte bir kez oynar (ref + // tutulmaz; Strict Mode'un çift effect'i temizlik sonrası yeniden kurar). + if (!kap || !hazir) return; + const sahne = kap.querySelector("[data-sahne]"); + const logo = kap.querySelector("[data-logo]"); + const kareler = Array.from( + kap.querySelectorAll("[data-kare]"), + ); + const logoKareleri = Array.from( + kap.querySelectorAll("[data-logo-kare]"), + ); + const sonra = Array.from(kap.querySelectorAll("[data-sonra]")); + const kontroller: { stop: () => void }[] = []; + const sayaclar: number[] = []; + const dokunulan: HTMLElement[] = []; + + const kartli = !!sahne && kareler.length > 0; + const kartsiz = !kartli && !!logo && logoKareleri.length > 0; + + if (azHareket || (!kartli && !kartsiz)) { + // Logo kabı sahnesiz anlamsız: yer kaplamasın. + if (logo) { + logo.style.display = "none"; + dokunulan.push(logo); + } + kontroller.push( + animate(kap, { opacity: [0, 1] }, { duration: 0.25, ease: "easeOut" }), + ); + return () => { + kontroller.forEach((k) => k.stop()); + for (const el of dokunulan) el.style.display = ""; + }; + } + + // Bulanıklıktan netleşerek, hafifçe aşağıdan gelen satır. + const netlestir = (el: HTMLElement, gecikme: number) => { + el.style.opacity = "0"; + dokunulan.push(el); + const k = animate( + el, + { + opacity: [0, 1], + filter: ["blur(10px)", "blur(0px)"], + y: [10, 0], + }, + { duration: 0.5, delay: gecikme, ease: GUCLU_CIKIS }, + ); + k.then(() => { + el.style.filter = ""; + }); + kontroller.push(k); + }; + + const temizle = () => { + kontroller.forEach((k) => k.stop()); + sayaclar.forEach((s) => window.clearTimeout(s)); + for (const el of dokunulan) { + el.style.opacity = ""; + el.style.filter = ""; + el.style.transform = ""; + el.style.transition = ""; + el.style.backgroundColor = ""; + el.style.borderRadius = ""; + el.style.height = ""; + el.style.display = ""; + } + }; + + if (kartsiz) { + // Kartsız: logo kareleri belirir, durur, söner; kap sönerken sıfır boya + // iner ki altında boşluk kalmasın; içerik ICERIK_BASI'ndan itibaren gelir. + let sonKare = 0; + logoKareleri.forEach((el) => { + const slot = (el.dataset.logoKare ?? "sol") as Slot; + const gecikme = BASLANGIC + SLOT_SIRASI.indexOf(slot) * SLOT_ADIMI; + sonKare = Math.max(sonKare, gecikme); + el.style.opacity = "0"; + dokunulan.push(el); + kontroller.push( + animate( + el, + { + opacity: [0, 1, 1, 0], + scale: [0.4, 1, 1, 0.6], + }, + { + duration: LOGO_SURE, + delay: gecikme, + times: LOGO_ZAMANLARI, + ease: [GUCLU_CIKIS, "linear", YERLESME], + }, + ), + ); + }); + const sonmeBasi = sonKare + LOGO_SURE * LOGO_ZAMANLARI[2]; + dokunulan.push(logo!); + const kapat = animate( + logo!, + { height: [logo!.offsetHeight, 0] }, + { duration: 0.4, delay: sonmeBasi, ease: YERLESME }, + ); + kapat.then(() => { + logo!.style.display = "none"; + }); + kontroller.push(kapat); + + sonra.forEach((el, i) => netlestir(el, ICERIK_BASI + i * 0.08)); + return temizle; + } + + // Kartlı: logo, kare alanının ortasında; 180'lik viewBox'ın ekrandaki boyu. + const W = sahne!.offsetWidth; + const H = sahne!.offsetHeight; + const logoBoy = Math.min(W * 0.5, 200); + const olcek = logoBoy / 180; + const logoX = (W - logoBoy) / 2; + const logoY = (H - logoBoy) / 2; + + let sonKare = 0; + kareler.forEach((el, i) => { + const slot = LOGO[el.dataset.kare as keyof typeof LOGO] + ? el.dataset.kare! + : "sol"; + const logoKare = LOGO[slot as keyof typeof LOGO]; + // offset*: transform'dan etkilenmez (çekmece o sırada kayıyor). + let sol = 0; + let ust = 0; + for ( + let d: HTMLElement | null = el; + d && d !== sahne; + d = d.offsetParent as HTMLElement | null + ) { + sol += d.offsetLeft; + ust += d.offsetTop; + } + const w = el.offsetWidth; + const h = el.offsetHeight; + const boy = logoKare.boyut * olcek; + const sx = boy / w; + const sy = boy / h; + const dx = logoX + logoKare.x * olcek + boy / 2 - (sol + w / 2); + const dy = logoY + logoKare.y * olcek + boy / 2 - (ust + h / 2); + // Ölçek köşeyi de küçültür; logodaki köşe oranı için ters ölçekli elips. + const rx = (LOGO_RX * olcek) / sx; + const ry = (LOGO_RX * olcek) / sy; + const logoKose = `${rx}px / ${ry}px`; + const kose = getComputedStyle(el).borderTopLeftRadius; + const gecikme = + BASLANGIC + + SLOT_SIRASI.indexOf(slot as Slot) * SLOT_ADIMI; + sonKare = Math.max(sonKare, gecikme); + + el.style.opacity = "0"; + el.style.backgroundColor = logoKare.renk; + dokunulan.push(el); + const k = animate( + el, + { + opacity: [0, 1, 1, 1], + x: [dx, dx, dx, 0], + y: [dy, dy, dy, 0], + scaleX: [sx * 0.4, sx, sx, 1], + scaleY: [sy * 0.4, sy, sy, 1], + borderRadius: [logoKose, logoKose, logoKose, `${kose} / ${kose}`], + }, + { + duration: KARE_SURE, + delay: gecikme, + times: KARE_ZAMANLARI, + ease: [GUCLU_CIKIS, "linear", YERLESME], + }, + ); + k.then(() => { + el.style.borderRadius = ""; + }); + kontroller.push(k); + // Yerine büyürken logo renginden karenin kendi rengine (sınıftaki renk). + sayaclar.push( + window.setTimeout( + () => { + el.style.transition = "background-color 450ms ease-out"; + el.style.backgroundColor = ""; + }, + (gecikme + KARE_SURE * KARE_ZAMANLARI[2]) * 1000, + ), + ); + + Array.from(el.children).forEach((satir, j) => + netlestir( + satir as HTMLElement, + gecikme + ICERIK_BASI + i * 0.06 + j * 0.05, + ), + ); + }); + + sonra.forEach((el, i) => + netlestir(el, sonKare + ICERIK_BASI + 0.3 + i * 0.08), + ); + + return temizle; + }, [azHareket, hazir]); + + return ( +
+ {children} +
+ ); +} diff --git a/src/features/katalog/components/akademik-ayrinti.tsx b/src/features/katalog/components/akademik-ayrinti.tsx index 1230dbf..1d0092f 100644 --- a/src/features/katalog/components/akademik-ayrinti.tsx +++ b/src/features/katalog/components/akademik-ayrinti.tsx @@ -1,7 +1,6 @@ "use client"; -import { useLayoutEffect, useRef, type ReactNode } from "react"; -import { animate, useReducedMotion } from "motion/react"; +import type { ReactNode } from "react"; import { ChevronRight, GraduationCap, X } from "lucide-react"; import { AltSayfa, @@ -12,6 +11,7 @@ import { AltSayfaBaslik, AltSayfaTetik, } from "@/components/ui/alt-sayfa"; +import { AcilisSahnesi } from "@/components/ui/acilis-sahnesi"; /** * Üniversite ve bölüm sayfasında özet kartlarının altındaki ayrıntı satırı @@ -21,7 +21,7 @@ import { * önceden söyler. Ayrıntı alttan açılan çekmecede; çekmece ve kapatma * çarpısı cta-sira-form.tsx'teki çekmeceyle birebir aynı sınıf setinde. * Kartlar sunucuda üretilip `children` olarak gelir; açılış koreografisini - * AcilisSahnesi oynatır. + * AcilisSahnesi (`src/components/ui/acilis-sahnesi.tsx`, kartlı mod) oynatır. */ export function AkademikAyrinti({ baglam, @@ -82,189 +82,3 @@ export function AkademikAyrinti({ ); } - -// Logodaki üç kare (public/kolay-tercih-mark.svg, 180'lik viewBox): solda -// mavi, sağ üstte şeftali, sağ altta turuncu. animasyonlu-mark.tsx ile aynı. -const LOGO = { - sol: { x: 27, y: 56, boyut: 57, renk: "#3b82f6" }, - "sag-ust": { x: 91, y: 29, boyut: 56, renk: "#fdc7a7" }, - "sag-alt": { x: 91, y: 95, boyut: 56, renk: "#ff5a00" }, -} as const; -const LOGO_RX = 13; -const SLOT_SIRASI = ["sol", "sag-ust", "sag-alt"] as const; - -// Saniye. Kare: belir (logo) → bekle → yerine büyü; içerik kare oturunca. -const BASLANGIC = 0.1; -const SLOT_ADIMI = 0.08; -const KARE_SURE = 1.15; -const KARE_ZAMANLARI = [0, 0.24, 0.52, 1]; -const ICERIK_BASI = 0.95; -const GUCLU_CIKIS = [0.23, 1, 0.32, 1] as const; -const YERLESME = [0.32, 0.72, 0, 1] as const; - -/** - * Çekmece açılınca oynayan sahne: renkli kareler önce ortada logonun üç - * karesi gibi belirir, sonra kendi yerlerine ve boylarına büyür (renk logo - * renginden kendi rengine döner), en son içleri satır satır bulanıklıktan - * netleşerek dolar; ardından alttaki not, buton ve kaynaklar gelir. - * - * Sunucu işaretlemesiyle sözleşme (universite-icerik.tsx, AkademikGostergeler): - * `data-sahne` kareleri saran `relative` kap, `data-kare="sol|sag-ust|sag-alt"` - * karenin logodaki yeri (aynı yeri paylaşan kareler üst üste başlar, sonra - * ayrışır), `data-sonra` karelerden sonra gelen bloklar. - * - * Kareler yalnız transform ile oynar (yerleşim ölçülür, bozulmaz); içerik o - * sırada görünmez olduğu için kare ölçeklenirken yazı ezilmez. "Hareketi - * azalt"ta logo ve büyüme yok, yalnız kısa belirme (DESIGN.md §6). - */ -function AcilisSahnesi({ children }: { children: ReactNode }) { - const kapRef = useRef(null); - const azHareket = useReducedMotion(); - - useLayoutEffect(() => { - const kap = kapRef.current; - if (!kap) return; - const sahne = kap.querySelector("[data-sahne]"); - const kareler = Array.from( - kap.querySelectorAll("[data-kare]"), - ); - const sonra = Array.from(kap.querySelectorAll("[data-sonra]")); - const kontroller: { stop: () => void }[] = []; - const sayaclar: number[] = []; - const dokunulan: HTMLElement[] = []; - - if (azHareket || !sahne || kareler.length === 0) { - kontroller.push( - animate(kap, { opacity: [0, 1] }, { duration: 0.25, ease: "easeOut" }), - ); - return () => kontroller.forEach((k) => k.stop()); - } - - // Bulanıklıktan netleşerek, hafifçe aşağıdan gelen satır. - const netlestir = (el: HTMLElement, gecikme: number) => { - el.style.opacity = "0"; - dokunulan.push(el); - const k = animate( - el, - { - opacity: [0, 1], - filter: ["blur(10px)", "blur(0px)"], - y: [10, 0], - }, - { duration: 0.5, delay: gecikme, ease: GUCLU_CIKIS }, - ); - k.then(() => { - el.style.filter = ""; - }); - kontroller.push(k); - }; - - // Logo, kare alanının ortasında; 180'lik viewBox'ın ekrandaki boyu. - const W = sahne.offsetWidth; - const H = sahne.offsetHeight; - const logoBoy = Math.min(W * 0.5, 200); - const olcek = logoBoy / 180; - const logoX = (W - logoBoy) / 2; - const logoY = (H - logoBoy) / 2; - - let sonKare = 0; - kareler.forEach((el, i) => { - const slot = LOGO[el.dataset.kare as keyof typeof LOGO] - ? el.dataset.kare! - : "sol"; - const logo = LOGO[slot as keyof typeof LOGO]; - // offset*: transform'dan etkilenmez (çekmece o sırada kayıyor). - let sol = 0; - let ust = 0; - for ( - let d: HTMLElement | null = el; - d && d !== sahne; - d = d.offsetParent as HTMLElement | null - ) { - sol += d.offsetLeft; - ust += d.offsetTop; - } - const w = el.offsetWidth; - const h = el.offsetHeight; - const boy = logo.boyut * olcek; - const sx = boy / w; - const sy = boy / h; - const dx = logoX + logo.x * olcek + boy / 2 - (sol + w / 2); - const dy = logoY + logo.y * olcek + boy / 2 - (ust + h / 2); - // Ölçek köşeyi de küçültür; logodaki köşe oranı için ters ölçekli elips. - const rx = (LOGO_RX * olcek) / sx; - const ry = (LOGO_RX * olcek) / sy; - const logoKose = `${rx}px / ${ry}px`; - const kose = getComputedStyle(el).borderTopLeftRadius; - const gecikme = - BASLANGIC + - SLOT_SIRASI.indexOf(slot as (typeof SLOT_SIRASI)[number]) * SLOT_ADIMI; - sonKare = Math.max(sonKare, gecikme); - - el.style.opacity = "0"; - el.style.backgroundColor = logo.renk; - dokunulan.push(el); - const k = animate( - el, - { - opacity: [0, 1, 1, 1], - x: [dx, dx, dx, 0], - y: [dy, dy, dy, 0], - scaleX: [sx * 0.4, sx, sx, 1], - scaleY: [sy * 0.4, sy, sy, 1], - borderRadius: [logoKose, logoKose, logoKose, `${kose} / ${kose}`], - }, - { - duration: KARE_SURE, - delay: gecikme, - times: KARE_ZAMANLARI, - ease: [GUCLU_CIKIS, "linear", YERLESME], - }, - ); - k.then(() => { - el.style.borderRadius = ""; - }); - kontroller.push(k); - // Yerine büyürken logo renginden karenin kendi rengine (sınıftaki renk). - sayaclar.push( - window.setTimeout( - () => { - el.style.transition = "background-color 450ms ease-out"; - el.style.backgroundColor = ""; - }, - (gecikme + KARE_SURE * KARE_ZAMANLARI[2]) * 1000, - ), - ); - - Array.from(el.children).forEach((satir, j) => - netlestir( - satir as HTMLElement, - gecikme + ICERIK_BASI + i * 0.06 + j * 0.05, - ), - ); - }); - - sonra.forEach((el, i) => - netlestir(el, sonKare + ICERIK_BASI + 0.3 + i * 0.08), - ); - - return () => { - kontroller.forEach((k) => k.stop()); - sayaclar.forEach((s) => window.clearTimeout(s)); - for (const el of dokunulan) { - el.style.opacity = ""; - el.style.filter = ""; - el.style.transform = ""; - el.style.transition = ""; - el.style.backgroundColor = ""; - el.style.borderRadius = ""; - } - }; - }, [azHareket]); - - return ( -
- {children} -
- ); -}