v1-beta-tema #9
@@ -9,6 +9,11 @@ This version has breaking changes — APIs, conventions, and file structure may
|
||||
|
||||
**İstisna: `tasarimci` ve UX işi.** Tasarımcı, gerekli gördüğünde önizlemeyi/dev sunucusunu **açabilir**. Bazı tasarım sorunları koddan teşhis edilemez — taşma, üst üste binme, kırılma noktaları, gerçek metin uzunluğuyla oluşan yerleşim, karanlık mod, odak halkası, dokunma hedefi. Bunlarda "koddan çıkarım" yetmez; bakmak gerekir.
|
||||
|
||||
**Zorunlu: yeni UI parçası tasarlayan herkes bakar (9 Eki 2026).** Yukarıdaki "açma" varsayılanı küçük düzeltmeler içindir (metin, renk, tek sınıf değişikliği). İş **yeni bir bileşen/bölüm tasarlamak ya da mevcut bir bölümü yeniden düzenlemek** ise — rol ne olursa olsun, ana oturum dahil — sonuç ekranda görülmeden teslim edilmez. 9 Ekim'de sürüm notlarındaki geri bildirim formu bakılmadan teslim edildi; DESIGN.md sınıfları birebir doğruydu ama sonuç kötüydü (kutu içinde kutu, sayfa başlıklarının yanında cılız başlık, soluk küçük buton) ve Bilal uyarmak zorunda kaldı. Sınıfların doğru olması kompozisyonun iyi olduğunu göstermez; oran ve hiyerarşi koddan görünmez. Yapılacaklar:
|
||||
- Önce Bilal'in açık dev sunucusu var mı bak (`lsof -iTCP -sTCP:LISTEN -P -n | grep node`); varsa onu kullan, yeni sunucu açma ve onunkini kapatma. Yoksa aç, iş bitince kapat.
|
||||
- **375 px ve masaüstünde** ekran görüntüsü al; ilk hâli beğenmediysen düzelt, tekrar bak. Raporda hangi genişlikte ne gördüğünü ve neye bakmadığını (ör. açık tema) yaz.
|
||||
- Yazmadan önce parçayı **sayfanın geri kalanıyla** ölç: başlık o sayfadaki kardeş başlıklarla aynı ölçekte olur; kart içine kenarlıklı alan konmaz (kutu içinde kutu) — form için tek yüzey: kenarlıksız `Textarea` + buton aynı yüzeyin içinde (sohbet kutusu kalıbı, örnek `src/features/geri-bildirim/components/geri-bildirim-formu.tsx`); aksiyon butonu kanonik turuncu CTA'dır (`size="lg"`), soluk `h-9` değil.
|
||||
|
||||
Açtığında geçerli olanlar:
|
||||
- **Gerekçesini yaz:** neyi koddan göremediğin için açtın.
|
||||
- **Ne gördüğünü yaz**, hangi genişlikte (375 / 768 / 1024 / 1280 px) ne olduğunu tek tek. Ekran görüntüsü alabilirsin.
|
||||
|
||||
@@ -133,6 +133,22 @@ Seçici `src/components/tema-secici.tsx` (footer'da), sağlayıcı `src/componen
|
||||
(`next-themes`, `<html class="dark">`). Renklerin tek kaynağı `src/app/globals.css` içindeki
|
||||
`.dark` bloğu.
|
||||
|
||||
**Geçiş ve kısayol (7 Eki 2026; son hâli 9 Eki):** tema değişimi anlık değildir; yeni
|
||||
tema header'daki logodan **yumuşak bir halka** gibi açılır: kenarı keskin değil, geniş ve
|
||||
tüylü (ışık yayılır gibi), kısa ve hafif: 600 ms. **Geçiş bitince** logonun kareleri
|
||||
yeni temanın biçimine dönüşür: koyuya geçişte şeftali kare hilale, mavi kare hilalin iki ucunun
|
||||
ortasındaki yıldıza (turuncu söner); açığa geçişte turuncu kare güneşe, şeftali kare halesine. Kısa bir an durur, yeniden
|
||||
logoya toplanır (`animasyonlu-mark.tsx` `temaSekli`, 1,7 sn; yalnız header'daki logo; hover döngüsünden
|
||||
önceliklidir, biçim bitince imleç üzerindeyse döngü kaldığı yerden değil yerinden sürer). Tema
|
||||
seçicinin ikonları aynı biçimlerin durağan hâlidir (ayrıntı ve kurallar §4.34) (`tema-ikonu.tsx`: güneş, ay, Sistem için
|
||||
logonun üç karesi); burada lucide ikonu kullanılmaz. View Transitions + radyal degrade
|
||||
maskesi: `src/lib/tema-gecisi.ts`, `globals.css` `.kt-tema-gecis`; hareket azaltılmışsa düz
|
||||
değişir. Denenip bırakılanlar: logo biçimli maske ("logo içinde logo"), düz crossfade, piksel
|
||||
ızgarası (sert bulundu). Kural: bu geçişte sert kenar ve ikinci bir şekil yok.
|
||||
`D` tuşu açık ↔ koyu geçişi yapar (`src/components/tema-kisayolu.tsx`); ipucu seçicinin
|
||||
altında `Kbd` ile durur, başka yere kopyalanmaz. Header'daki Beta rozeti `/surum-notlari`na
|
||||
bağlantıdır.
|
||||
|
||||
**Nasıl çalışır:** bileşenlere `dark:` yazılmaz; palet token'ları çevrilir.
|
||||
|
||||
| Açık temadaki sınıf | Koyu temada ne olur |
|
||||
@@ -698,6 +714,43 @@ genişletilebilir olmalı; UI için prompt-kit kullan."
|
||||
zaman yazılıdır (§2.2). Sohbet ilerlese de kalır. Yeni bir liste uyarısı gerekince
|
||||
`listeUyarilari`'na eklenir, sayfaya yeni kutu açılmaz.
|
||||
|
||||
### 4.34 Logo biçim dönüşümü (morph) — `AnimasyonluMark` `temaSekli`, `tema-ikonu.tsx`
|
||||
|
||||
(Numara 4.34: `v2` dalındaki belge 4.21–4.33'ü kullanıyor; iki dal birleşince çakışmasın.)
|
||||
|
||||
Bilal'in kararı (9 Eki 2026): logonun üç karesi yalnız süs değildir; **bir durumu anlatmak
|
||||
için kısa bir an başka bir biçime dönüşüp geri toplanabilir.** İlk ve şimdilik tek kullanım
|
||||
tema değişimidir (§2.3). Bu, markanın yumuşak ve oyunsu tarafının imza hareketidir; piksel
|
||||
imzası (§1) durağan dokuysa bu onun canlı karşılığıdır.
|
||||
|
||||
**Biçimler** (180'lik logo kutusunda; renkler logonun kendi renkleri, iki temada aynı):
|
||||
|
||||
| Durum | Mavi kare | Şeftali kare | Turuncu kare |
|
||||
|---|---|---|---|
|
||||
| Açık tema: **güneş** | merkezde söner | hale: büyük, yarı saydam daire | güneş: küçük dolu daire |
|
||||
| Koyu tema: **ay** | yıldız: hilalin iki sivri ucunun tam ortasında | hilal: sırtı solda | yıldızın yerinde söner |
|
||||
| Sistem (yalnız ikon) | logo karesi | logo karesi | logo karesi |
|
||||
|
||||
Simetri kasıtlıdır: güneşte mavi, ayda turuncu sahneden çekilir. Hilal, kareyi kesen ikinci
|
||||
bir şekille değil karenin kendi iç gölgesiyle çizilir (zemin rengine bağlı kalmaz).
|
||||
|
||||
**Koreografi:** toplam 1,7 sn — %30'da biçim tamam, %70'e kadar durur, sonra logoya döner.
|
||||
Kareler yuvarlanırken yer değiştirir ve boy değiştirir; sekme yok (§6). Sayfa geçişiyle
|
||||
**üst üste binmez**: önce sayfa geçişi biter (600 ms), kısa bir nefes, sonra logo dönüşür.
|
||||
Hover döngüsünden önceliklidir; biçim bitince imleç hâlâ üzerindeyse hover yerinden sürer.
|
||||
"Hareketi azalt" açıksa dönüşüm oynamaz.
|
||||
|
||||
**Durağan hâli:** tema seçicinin ikonları aynı biçimlerdir (`tema-ikonu.tsx`). Logo ile ikon
|
||||
aynı geometriyi kullanır; biri değişirse öbürü de aynı commit'te değişir.
|
||||
|
||||
**Kurallar:**
|
||||
- Dönüşüm yalnız header'daki logoda oynar; aynı ekranda iki logo birden dönüşmez.
|
||||
- Bir **durum değişimini** anlatır (tema gibi), dekor olarak döngüye alınmaz, sayfa
|
||||
açılışında oynatılmaz.
|
||||
- Yeni bir biçim eklenecekse üç kareden ve logonun üç renginden çıkar; dördüncü şekil,
|
||||
yeni renk, çizgi ikon eklenmez. Her biçimin durağan ikon karşılığı da yazılır.
|
||||
- Biçim logonun kendi alanına sığar (x 27–151): sağındaki "KolayTercih" yazısına değmez.
|
||||
|
||||
## 5. Yerleşim
|
||||
|
||||
### 5.1 Genel
|
||||
@@ -767,6 +820,8 @@ Hareket kuralları (Apple, `motion/react`):
|
||||
- Kaydırmaya bağlı tetik `IntersectionObserver` ile (kaydırma dinleyicisi değil).
|
||||
- "Hareketi azalt": kayma/yay yok, yalnız opaklık (`useReducedMotion`,
|
||||
`motion-reduce:transition-opacity`).
|
||||
- Logo kareleri bir durumu anlatmak için kısa bir an başka biçime dönüşebilir (§4.34); sayfa
|
||||
geçişiyle üst üste binmez, sırayla oynar.
|
||||
|
||||
## 7. Yap / Yapma
|
||||
|
||||
@@ -784,6 +839,7 @@ Hareket kuralları (Apple, `motion/react`):
|
||||
| Gösterge kareleri masonry (kaymış sütun, farklı boy) | Hizalı eşit ızgara; 8 px'lik "neredeyse hizalı" kaymalar |
|
||||
| Liste satırında ad sarar, sayı alt satıra iner | `truncate` ile yarım ad; 32 px satır butonu |
|
||||
| Hareket: `Reveal`, piksel ısısı, `kt-unblur`, sekmesiz yay; `animate-ping` yalnızca "şu anda canlı" noktasında | Her elemanda `fade-up`, `whileHover scale 1.05`, `animate-bounce` |
|
||||
| Tema ikonu ve logo dönüşümü: logonun üç karesinden güneş / ay (§4.34) | Tema seçicide lucide `Sun`/`Moon`; logoyu döngüde sürekli dönüştürmek |
|
||||
| Başlık `font-heading`, vurgu `font-bricolage` | Inter/Roboto/serif, tek kelimelik BÜYÜK HARF etiket, 10–11 px yazı |
|
||||
| Risk rengi + metin etiketi; tür ayrımı ikonla | Renk tek başına, "yerleşirsin" gibi vaat cümlesi |
|
||||
| Üç kart yalnızca üç gerçek öğe varsa | Hero + üç kart iskeleti refleksi |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kolaytercih",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -476,3 +476,47 @@
|
||||
.toaster [data-sonner-toast][data-styled="true"] [data-button]:hover {
|
||||
background: var(--color-slate-700);
|
||||
}
|
||||
|
||||
/* Tema geçişi (DESIGN.md §2.3, lib/tema-gecisi.ts): yeni tema header'daki
|
||||
logodan yumuşak bir halka gibi açılır. Eski görüntü yerinde durur; yeni
|
||||
görüntü radyal degrade maskesiyle üstünde belirir. Maskenin kenarı keskin
|
||||
değil: yarıçapın %55'ine kadar tam opak, oradan kenara doğru tüy gibi
|
||||
söner (--kt-tema-cekirdek; JS'teki CEKIRDEK ile aynı olmalı) — ışık
|
||||
yayılır gibi, sert çizgi yok. Yarıçap (--kt-tema-r) 0 → --kt-tema-son
|
||||
yumuşak kalkıp yumuşak durur; kısa ve hafif (600 ms) — asıl gösteri,
|
||||
geçiş bitince oynayan logo dönüşümüdür. Merkez ve son yarıçap JS'ten gelir.
|
||||
Hareket azaltılmışsa JS geçişi hiç başlatmaz, tema düz değişir. */
|
||||
@property --kt-tema-r {
|
||||
syntax: "<length>";
|
||||
inherits: true;
|
||||
initial-value: 0px;
|
||||
}
|
||||
.kt-tema-gecis {
|
||||
view-transition-name: root;
|
||||
--kt-tema-cekirdek: 0.55;
|
||||
}
|
||||
.kt-tema-gecis::view-transition-old(root),
|
||||
.kt-tema-gecis::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
.kt-tema-gecis::view-transition-old(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
.kt-tema-gecis::view-transition-new(root) {
|
||||
z-index: 2;
|
||||
mask-image: radial-gradient(
|
||||
circle at var(--kt-tema-x) var(--kt-tema-y),
|
||||
#000 calc(var(--kt-tema-r) * var(--kt-tema-cekirdek)),
|
||||
transparent var(--kt-tema-r)
|
||||
);
|
||||
animation: kt-tema-halka 600ms cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
}
|
||||
@keyframes kt-tema-halka {
|
||||
from {
|
||||
--kt-tema-r: 0px;
|
||||
}
|
||||
to {
|
||||
--kt-tema-r: var(--kt-tema-son);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function RootLayout({
|
||||
<div
|
||||
id="icerik"
|
||||
tabIndex={-1}
|
||||
className="flex min-h-0 flex-1 flex-col pb-28 outline-none print:pb-0"
|
||||
className="flex min-h-0 flex-1 flex-col pb-28 outline-none has-[[data-site-footer]]:pb-0 print:pb-0"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -58,11 +58,9 @@ import {
|
||||
import type { Metadata } from "next";
|
||||
import {
|
||||
JsonLd,
|
||||
KATALOG_VERI_TARIHI,
|
||||
faqPageJsonLd,
|
||||
softwareApplicationJsonLd,
|
||||
} from "@/lib/seo";
|
||||
import { KATALOG_TARIH_METNI } from "@/features/katalog/components/veri-kaynagi";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -257,27 +255,6 @@ export default function Home() {
|
||||
>
|
||||
<HeroForm />
|
||||
</div>
|
||||
<div className="mt-3 inline-flex items-center gap-2.5 rounded-lg border border-slate-200 bg-card px-3 py-2 text-left text-xs text-slate-700 shadow-xs">
|
||||
<span>
|
||||
<strong className="font-semibold text-slate-900">
|
||||
Veri: YÖK Atlas
|
||||
</strong>{" "}
|
||||
· son güncelleme{" "}
|
||||
<time dateTime={KATALOG_VERI_TARIHI}>
|
||||
{KATALOG_TARIH_METNI}
|
||||
</time>
|
||||
</span>
|
||||
</div>
|
||||
{/* Markayı tek cümlede tanımlayan görünür metin: sitedeki SSS ve
|
||||
/kosullar ifadelerinin toplamı, yeni vaat yok. Meta/OG'de zaten
|
||||
var; yapay zekâ cevap motorları gövde metnini önceliklendirdiği
|
||||
için burada da durur (docs/arastirma/2026-09-24-geo-seo). */}
|
||||
<p className="mt-5 max-w-2xl text-sm leading-6 text-slate-600">
|
||||
KolayTercih, YÖK Atlas'ın resmî yerleştirme verisiyle
|
||||
sıralamana uygun 24 tercihlik dengeli liste kuran, yapay zekâ
|
||||
destekli bir karar destek aracıdır. ÖSYM ya da YÖK ile bağlantılı
|
||||
değildir; yerleşme garantisi vermez.
|
||||
</p>
|
||||
</Parallax>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ const staticEntries: MetadataRoute.Sitemap = [
|
||||
{ url: `${SITE_URL}/paket`, priority: 0.7, changeFrequency: "monthly" },
|
||||
{ url: `${SITE_URL}/meraklisina`, priority: 0.6, changeFrequency: "monthly" },
|
||||
{ url: `${SITE_URL}/iletisim`, priority: 0.3, changeFrequency: "yearly" },
|
||||
{ url: `${SITE_URL}/surum-notlari`, priority: 0.3, changeFrequency: "monthly" },
|
||||
{ url: `${SITE_URL}/gizlilik`, priority: 0.2, changeFrequency: "yearly" },
|
||||
{
|
||||
url: `${SITE_URL}/cerez-politikasi`,
|
||||
|
||||
114
src/app/surum-notlari/page.tsx
Normal file
114
src/app/surum-notlari/page.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ArayuzBilgisi } from "@/components/arayuz-bilgisi";
|
||||
import { Parallax } from "@/components/parallax";
|
||||
import { GeriBildirimFormu } from "@/features/geri-bildirim/components/geri-bildirim-formu";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { SURUM } from "@/lib/surum";
|
||||
import { sayfaMetadata } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = sayfaMetadata({
|
||||
title: "Sürüm notları",
|
||||
description:
|
||||
"KolayTercih beta sürüm notları: neler değişti ve geri bildirimini nasıl iletirsin.",
|
||||
path: "/surum-notlari",
|
||||
});
|
||||
|
||||
/**
|
||||
* Sürüm günlüğü. En yeni en üstte; her sürüm package.json'daki numarayla
|
||||
* aynı adı taşır (AGENTS.md "Sürümleme"). Yeni bir sürüm çıkarken buraya bir
|
||||
* kayıt eklenir. Yalnız görünüşü etkileyen değişiklikler `arayuz` alanına
|
||||
* yazılır: listede tek satır ("Arayüz iyileştirmeleri") olur, ayrıntısı
|
||||
* yanındaki bilgi düğmesinin balonunda durur.
|
||||
*/
|
||||
const SURUMLER: {
|
||||
surum: string;
|
||||
tarih: string;
|
||||
maddeler: string[];
|
||||
arayuz?: string[];
|
||||
}[] = [
|
||||
{
|
||||
surum: "1.1.0",
|
||||
tarih: "7 Ekim 2026",
|
||||
maddeler: [],
|
||||
arayuz: [
|
||||
"D tuşu açık / koyu temayı değiştirir; geçiş logodan yayılarak yumuşak oynar.",
|
||||
"Bu sayfa: beta durumu, sürüm günlüğü ve geri bildirim kutusu. Header'daki Beta rozeti buraya getirir.",
|
||||
],
|
||||
},
|
||||
{
|
||||
surum: "1.0.0",
|
||||
tarih: "7 Ekim 2026",
|
||||
maddeler: [
|
||||
"Sıralama, alan, il ve devlet/vakıf tercihine göre 24 tercihlik dengeli liste: hayal, dengeli ve güvenli dilimler.",
|
||||
"Her program için YÖK Atlas verisinden taban sıralama, kontenjan ve yıllara göre eğilim.",
|
||||
"Yapay zekâ destekli tercih raporu ve liste üzerinde soru sorabildiğin danışman sohbeti.",
|
||||
"Üniversite ve bölüm kataloğu, üniversite sıralamaları, rehber yazıları.",
|
||||
],
|
||||
arayuz: ["Açık, koyu ve sistem teması."],
|
||||
},
|
||||
];
|
||||
|
||||
export default function SurumNotlariPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="mx-auto w-full max-w-2xl px-4 py-16">
|
||||
<Parallax fromStart strength={10}>
|
||||
<h1 className="font-heading text-3xl font-bold">Sürüm notları</h1>
|
||||
<p className="mt-3 text-slate-600">
|
||||
KolayTercih şu an beta sürümünde. Ürün çalışıyor ve gerçek YÖK
|
||||
Atlas verisiyle liste üretiyor; ama hâlâ her gün bir şeyler
|
||||
düzeltiyor, eksik gördüğümüz yerleri tamamlıyoruz. Her sürümde
|
||||
neyin değiştiği aşağıda.
|
||||
</p>
|
||||
</Parallax>
|
||||
|
||||
{/* Zaman çizgisi: her sürümün piksel karesi solda durur, kareleri
|
||||
ince bir çizgi birbirine bağlar (karenin ortasından bir sonraki
|
||||
karenin ortasına). En eski sürümde çizgi biter. */}
|
||||
<ol aria-label="Sürümler" className="mt-12">
|
||||
{SURUMLER.map((s, i) => (
|
||||
<li key={s.surum} className="relative pb-12 pl-7 last:pb-0">
|
||||
{i < SURUMLER.length - 1 && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute top-4 -bottom-4 left-[4.5px] w-px bg-slate-200 sm:top-[18px] sm:-bottom-[18px]"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute top-[11px] left-0 size-2.5 rounded-[2px] bg-primary sm:top-[13px]"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<h2 className="font-heading text-2xl font-bold text-slate-900 sm:text-3xl">
|
||||
v{s.surum}
|
||||
</h2>
|
||||
<time className="text-sm text-slate-500">{s.tarih}</time>
|
||||
{s.surum === SURUM && (
|
||||
<span className="text-xs font-semibold text-primary">
|
||||
Şu anki sürüm
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ul className="mt-3 list-disc space-y-2 pl-5 text-base leading-relaxed text-slate-600 marker:text-slate-400">
|
||||
{s.maddeler.map((m) => (
|
||||
<li key={m}>{m}</li>
|
||||
))}
|
||||
{s.arayuz && (
|
||||
<li>
|
||||
<span className="inline-flex items-center gap-2 align-middle">
|
||||
Arayüz iyileştirmeleri
|
||||
<ArayuzBilgisi maddeler={s.arayuz} />
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<GeriBildirimFormu className="mt-16" />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { TEMA_GECIS_OLAYI } from "@/lib/tema-gecisi";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// public/kolay-tercih-mark.svg'nin inline, animasyonlu karşılığı.
|
||||
@@ -97,6 +98,51 @@ function yer(pozisyon: number, boyut: number): string {
|
||||
return `${(x / boyut) * 100}% ${(y / boyut) * 100}%`;
|
||||
}
|
||||
|
||||
/** Karenin merkezini 180'lik kutuda (cx, cy) noktasına taşıyan `translate`. */
|
||||
function merkezde(cx: number, cy: number, boyut: number): string {
|
||||
return `${((cx - boyut / 2) / boyut) * 100}% ${((cy - boyut / 2) / boyut) * 100}%`;
|
||||
}
|
||||
|
||||
// Tema geçişi biçimi (DESIGN.md §2.3): sayfa geçişi bitince kareler
|
||||
// neye geçildiğini gösterir, kısa bir an durur ve yeniden logoya toplanır.
|
||||
// - Koyuya: şeftali kare hilale, mavi kare hilalin kucağındaki yıldıza;
|
||||
// turuncu söner.
|
||||
// - Açığa: turuncu kare güneşe, şeftali kare arkasındaki haleye; mavi söner.
|
||||
// Toplam süre ve duraklar: %30'da biçim tamam, %70'e kadar durur, sonra döner.
|
||||
const SEKIL_SURE = 1700;
|
||||
const SEKIL_DURAKLAR = [0, 0.3, 0.7, 1];
|
||||
type SekilHedefi = { translate: string; scale: string; opacity: number };
|
||||
|
||||
// Ay kompozisyonu kutunun merkezine (90) göre küçültülür ve logonun alanına
|
||||
// (x 27–151) oturacak kadar kaydırılır.
|
||||
const AY_OLCEK = 0.82;
|
||||
const AY_KAYMA_X = 8;
|
||||
function ay(nokta: number, kayma = AY_KAYMA_X): number {
|
||||
return 90 + (nokta - 90) * AY_OLCEK + kayma;
|
||||
}
|
||||
|
||||
/** Kare `i` için hedef biçim. Kareler sırayla mavi, şeftali, turuncu. */
|
||||
function sekilHedefi(i: number, koyu: boolean): SekilHedefi {
|
||||
const b = KARELER[i].boyut;
|
||||
if (koyu) {
|
||||
return [
|
||||
// Biçim tema-ikonu.tsx'teki ay ikonuyla aynı: gövde r74 @ (76,92),
|
||||
// tek yıldız r13 @ (108,79) — hilalin iki sivri ucunun tam ortası (iki
|
||||
// dairenin kesişim kirişinin orta noktası). Turuncu kare yıldızın
|
||||
// yerinde söner: güneşte mavi, ayda turuncu sahneden çekilir. `ay`
|
||||
// kompozisyonu logonun kendi alanına sığdırır.
|
||||
{ translate: merkezde(ay(108), ay(79, 0), b), scale: String((26 * AY_OLCEK) / b), opacity: 1 }, // yıldız
|
||||
{ translate: merkezde(ay(76), ay(92, 0), b), scale: String((148 * AY_OLCEK) / b), opacity: 1 }, // hilal
|
||||
{ translate: merkezde(ay(108), ay(79, 0), b), scale: "0.2", opacity: 0 }, // söner
|
||||
][i];
|
||||
}
|
||||
return [
|
||||
{ translate: merkezde(90, 90, b), scale: "0.3", opacity: 0 }, // söner
|
||||
{ translate: merkezde(90, 90, b), scale: "2.15", opacity: 0.45 }, // hale
|
||||
{ translate: merkezde(90, 90, b), scale: "1.1", opacity: 1 }, // güneş
|
||||
][i];
|
||||
}
|
||||
|
||||
/** Kare için durak koordinatları: kendi yeri → sıradaki → ondan sonraki →
|
||||
* kendi yeri. Duraklarda bekleme için her hedef iki kez tekrarlanır
|
||||
* (KONUM_ZAMANLARI ile hizalı). */
|
||||
@@ -184,6 +230,7 @@ export function AnimasyonluMark({
|
||||
const kokRef = useRef<HTMLSpanElement>(null);
|
||||
const kontrolRef = useRef<{
|
||||
ayarla: (hedef: Varyant | null, cevik?: boolean) => void;
|
||||
temaSekli: (koyu: boolean) => void;
|
||||
} | null>(null);
|
||||
|
||||
// Animasyonların sahibi: bileşen yaşadıkça bir kez kurulur. Prop değişimi
|
||||
@@ -197,6 +244,8 @@ export function AnimasyonluMark({
|
||||
|
||||
let dongu: Animation[] = [];
|
||||
let donus: Animation[] = [];
|
||||
/** Tema geçişi biçimi (güneş/ay) animasyonları; bkz. `temaSekli`. */
|
||||
let sekil: Animation[] = [];
|
||||
/** Şu an dönen döngü ve istenen döngü (`null`: durağan). */
|
||||
let aktif: Varyant | null = null;
|
||||
let hedef: Varyant | null = null;
|
||||
@@ -233,14 +282,11 @@ export function AnimasyonluMark({
|
||||
);
|
||||
};
|
||||
|
||||
const ayarla = (yeni: Varyant | null, cevik = false) => {
|
||||
if (yeni === hedef) return;
|
||||
hedef = yeni;
|
||||
if (aktif !== null) yerles(cevik);
|
||||
if (yeni === null || azHareket.matches) return;
|
||||
// Kareler hâlâ yerine dönüyorsa dönüşün bitmesi beklenir; döngü hep
|
||||
// yerinden başlar, sıçrama olmaz.
|
||||
Promise.all(donus.map((a) => a.finished))
|
||||
/** `yeni` döngüsünü, süren dönüş ve tema biçimi bitince yerinden başlatır. */
|
||||
const donguyuSiraya = (yeni: Varyant) => {
|
||||
// Kareler hâlâ yerine dönüyorsa (ya da güneş/ay oynuyorsa) bitmesi
|
||||
// beklenir; döngü hep yerinden başlar, sıçrama olmaz.
|
||||
Promise.all([...donus, ...sekil].map((a) => a.finished))
|
||||
.then(() => {
|
||||
if (sokuldu || hedef !== yeni || aktif !== null) return;
|
||||
dongu = kareler.flatMap((el, i) => donguyuBaslat(el, i, yeni));
|
||||
@@ -249,12 +295,94 @@ export function AnimasyonluMark({
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const kontrol = { ayarla };
|
||||
const ayarla = (yeni: Varyant | null, cevik = false) => {
|
||||
if (yeni === hedef) return;
|
||||
hedef = yeni;
|
||||
if (aktif !== null) yerles(cevik);
|
||||
if (yeni === null || azHareket.matches) return;
|
||||
donguyuSiraya(yeni);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tema geçişi biçimi: kareler güneşe ya da aya dönüşür, durur, logoya
|
||||
* döner. Hover döngüsünden önceliklidir: döngü oynuyorsa kesilir, kareler
|
||||
* o an neredeyse oradan biçime geçer; biçim bitince imleç hâlâ
|
||||
* üzerindeyse döngü yerinden yeniden başlar. Hareket azaltılmışsa çalışmaz.
|
||||
* Hilal, kareyi kesen ikinci bir şekille değil karenin kendi iç gölgesiyle
|
||||
* çizilir (zemin şeffaf, renk iç gölgede): arkadaki zemin ne olursa olsun
|
||||
* doğru görünür.
|
||||
*/
|
||||
const temaSekli = (koyu: boolean) => {
|
||||
if (azHareket.matches) return;
|
||||
// Karelerin o anki hâli: döngü ya da önceki biçim yarıdaysa oradan çıkılır.
|
||||
const anlik = kareler.map((el) => {
|
||||
const cs = getComputedStyle(el);
|
||||
return {
|
||||
translate: cs.translate,
|
||||
scale: cs.scale,
|
||||
opacity: cs.opacity,
|
||||
borderRadius: cs.borderRadius,
|
||||
};
|
||||
});
|
||||
[...dongu, ...donus, ...sekil].forEach((a) => a.cancel());
|
||||
dongu = [];
|
||||
donus = [];
|
||||
aktif = null;
|
||||
sekil = kareler.flatMap((el, i) => {
|
||||
const k = KARELER[i];
|
||||
const ev = {
|
||||
translate: yer(k.baslangic, k.boyut),
|
||||
scale: "1",
|
||||
opacity: 1,
|
||||
borderRadius: `${(13 / k.boyut) * 100}%`,
|
||||
};
|
||||
const bicim = { ...sekilHedefi(i, koyu), borderRadius: "50%" };
|
||||
const secenek = { duration: SEKIL_SURE };
|
||||
const animasyonlar = [
|
||||
el.animate(
|
||||
[
|
||||
{ ...anlik[i], easing: DONUS },
|
||||
{ ...bicim, easing: "linear" },
|
||||
{ ...bicim, easing: YERLES },
|
||||
ev,
|
||||
].map((kare, d) => ({ ...kare, offset: SEKIL_DURAKLAR[d] })),
|
||||
secenek,
|
||||
),
|
||||
];
|
||||
// Hilal: yalnız şeftali kare, yalnız koyuya geçerken.
|
||||
if (koyu && i === 1) {
|
||||
const renk = getComputedStyle(el).backgroundColor;
|
||||
const w = el.offsetWidth;
|
||||
const dolu = `inset 0 0 0 ${w}px ${renk}`;
|
||||
// tema-ikonu.tsx'teki hilalle aynı kesim: gövde r74'ten, (+40,-16)
|
||||
// kaydırılmış r66 daire çıkar — sırtı sol altta.
|
||||
// İç gölgede: kaydırma = kesim dairesinin kayması, yayılma = iki
|
||||
// yarıçapın farkı (hepsi gövde çapı 148'e oranla).
|
||||
const hilal = `inset ${(40 / 148) * w}px ${(-16 / 148) * w}px 0 ${(8 / 148) * w}px ${renk}`;
|
||||
animasyonlar.push(
|
||||
el.animate(
|
||||
[
|
||||
{ boxShadow: dolu, backgroundColor: "transparent", easing: DONUS },
|
||||
{ boxShadow: hilal, backgroundColor: "transparent", easing: "linear" },
|
||||
{ boxShadow: hilal, backgroundColor: "transparent", easing: YERLES },
|
||||
{ boxShadow: dolu, backgroundColor: "transparent" },
|
||||
].map((kare, d) => ({ ...kare, offset: SEKIL_DURAKLAR[d] })),
|
||||
secenek,
|
||||
),
|
||||
);
|
||||
}
|
||||
return animasyonlar;
|
||||
});
|
||||
// İmleç hâlâ üzerindeyse hover döngüsü biçimden sonra sürer.
|
||||
if (hedef !== null) donguyuSiraya(hedef);
|
||||
};
|
||||
|
||||
const kontrol = { ayarla, temaSekli };
|
||||
kontrolRef.current = kontrol;
|
||||
return () => {
|
||||
sokuldu = true;
|
||||
if (kontrolRef.current === kontrol) kontrolRef.current = null;
|
||||
[...dongu, ...donus].forEach((a) => a.cancel());
|
||||
[...dongu, ...donus, ...sekil].forEach((a) => a.cancel());
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -276,9 +404,18 @@ export function AnimasyonluMark({
|
||||
const cik = () => kontrolRef.current?.ayarla(null, true);
|
||||
hoverHedefi.addEventListener("mouseenter", gir);
|
||||
hoverHedefi.addEventListener("mouseleave", cik);
|
||||
// Tema geçişi bitince (lib/tema-gecisi.ts) kareler yeni temanın
|
||||
// biçimine dönüşür: koyuya geçişte ay ve yıldızlar, açığa geçişte güneş.
|
||||
// Olay anında <html> yeni temanın sınıfını taşır.
|
||||
const temaGecti = () =>
|
||||
kontrolRef.current?.temaSekli(
|
||||
document.documentElement.classList.contains("dark"),
|
||||
);
|
||||
window.addEventListener(TEMA_GECIS_OLAYI, temaGecti);
|
||||
return () => {
|
||||
hoverHedefi.removeEventListener("mouseenter", gir);
|
||||
hoverHedefi.removeEventListener("mouseleave", cik);
|
||||
window.removeEventListener(TEMA_GECIS_OLAYI, temaGecti);
|
||||
};
|
||||
}, [oynat, sadeceHover, varyant]);
|
||||
|
||||
|
||||
57
src/components/arayuz-bilgisi.tsx
Normal file
57
src/components/arayuz-bilgisi.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Info } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
/**
|
||||
* Sürüm notlarında "Arayüz iyileştirmeleri" satırının yanındaki bilgi düğmesi.
|
||||
* Düğme ve balon, danışman sohbetindeki "Danışmanın bildikleri" düğmesiyle
|
||||
* birebir aynıdır (`sohbet-client.tsx`). Fark: dokunmatikte üzerine gelme
|
||||
* olmadığı için dokununca da açılıp kapanır.
|
||||
*/
|
||||
export function ArayuzBilgisi({ maddeler }: { maddeler: string[] }) {
|
||||
const [acik, setAcik] = useState(false);
|
||||
// Basış anındaki durum: Radix basışta balonu kapatır, tık ona göre çevirir.
|
||||
const basistaAcikti = useRef(false);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={120}>
|
||||
<Tooltip open={acik} onOpenChange={setAcik}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Arayüz iyileştirmelerinin ayrıntısı"
|
||||
onPointerDown={() => {
|
||||
basistaAcikti.current = acik;
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setAcik(!basistaAcikti.current);
|
||||
}}
|
||||
className="relative inline-flex size-7 cursor-help items-center justify-center rounded-full border border-slate-200 bg-card text-slate-600 transition-colors after:absolute after:-inset-2 hover:bg-slate-100 hover:text-slate-800"
|
||||
>
|
||||
<Info className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
className="max-w-xs flex-col items-start gap-1.5 p-3 text-left leading-relaxed"
|
||||
>
|
||||
<p className="font-semibold">Bu sürümdeki arayüz iyileştirmeleri:</p>
|
||||
<ul className="flex list-disc flex-col gap-1 pl-4">
|
||||
{maddeler.map((m) => (
|
||||
<li key={m}>{m}</li>
|
||||
))}
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -25,8 +25,10 @@ export function SiteFooter() {
|
||||
.sort((a, b) => b.programSayisi - a.programSayisi)
|
||||
.slice(0, 8);
|
||||
|
||||
// data-site-footer: layout'taki #icerik alt boşluğunu (pb-28) kapatır —
|
||||
// dev yazı logosu ekranın altına yapışsın.
|
||||
return (
|
||||
<footer className="border-t border-slate-200/70 pb-32 pt-12">
|
||||
<footer data-site-footer className="border-t border-slate-200/70 pt-12">
|
||||
<div className="mx-auto max-w-6xl px-4">
|
||||
<div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<nav aria-label="Popüler bölümler">
|
||||
@@ -199,6 +201,12 @@ export function SiteFooter() {
|
||||
>
|
||||
İletişim
|
||||
</Link>
|
||||
<Link
|
||||
href="/surum-notlari"
|
||||
className="hover:text-slate-700 hover:underline"
|
||||
>
|
||||
Sürüm notları
|
||||
</Link>
|
||||
</nav>
|
||||
<TemaSecici className="mt-6" />
|
||||
{/* iyzico işyeri sözleşmesi gereği: "iyzico ile Öde" + kart logoları
|
||||
@@ -212,6 +220,41 @@ export function SiteFooter() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Dev yazı logosu: header'daki "KolayTercih" (Bricolage, ince + yarı
|
||||
kalın) kenardan kenara, alttan kırpılmış ve aşağı doğru sönerek.
|
||||
SVG + textLength: yazı her genişlikte kabı tam doldurur, font
|
||||
ölçüsüne göre taşmaz. Renkler slate token'ı — koyu temada kendiliğinden
|
||||
döner. Süs: ekran okuyucuya kapalı. */}
|
||||
<svg
|
||||
viewBox="0 0 1000 135"
|
||||
aria-hidden
|
||||
className="pointer-events-none mt-10 block w-full px-4 font-bricolage select-none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="footer-yazi-logo"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="135"
|
||||
>
|
||||
<stop offset="0" style={{ stopColor: "var(--color-slate-900)" }} />
|
||||
<stop offset="0.75" style={{ stopColor: "var(--color-slate-300)" }} />
|
||||
<stop offset="1" style={{ stopColor: "var(--color-slate-200)" }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<text
|
||||
x="0"
|
||||
y="156"
|
||||
fontSize="222"
|
||||
textLength="1000"
|
||||
fill="url(#footer-yazi-logo)"
|
||||
>
|
||||
<tspan className="font-light">Kolay</tspan>
|
||||
<tspan className="font-semibold">Tercih</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,10 +33,12 @@ export function SiteHeader() {
|
||||
Tercih
|
||||
</span>
|
||||
</span>
|
||||
{/* Beta rozeti + sürüm (surum-rozeti.tsx); yazı logosuyla aynı
|
||||
fade'i taşır ki NavBaslik yuvası açılınca birlikte sönsün. */}
|
||||
<SurumRozeti className="ml-2 transition-[opacity,translate] duration-300 ease-out in-data-[nav-baslik]:pointer-events-none in-data-[nav-baslik]:-translate-y-2 in-data-[nav-baslik]:opacity-0 motion-reduce:transition-opacity sm:ml-3" />
|
||||
</Link>
|
||||
{/* Beta rozeti + sürüm (surum-rozeti.tsx): /surum-notlari'na giden
|
||||
ayrı bir Link — logo Link'inin içinde bağlantı içinde bağlantı
|
||||
olmasın. Yazı logosuyla aynı fade'i taşır ki NavBaslik yuvası
|
||||
açılınca birlikte sönsün. */}
|
||||
<SurumRozeti className="ml-2 transition-[opacity,translate] duration-300 ease-out in-data-[nav-baslik]:pointer-events-none in-data-[nav-baslik]:-translate-y-2 in-data-[nav-baslik]:opacity-0 motion-reduce:transition-opacity sm:ml-3" />
|
||||
<div
|
||||
id="nav-baslik-yuvasi"
|
||||
className="pointer-events-none absolute inset-y-0 right-0 left-9 flex min-w-0 items-center sm:left-12 lg:right-auto lg:w-52 xl:w-72"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { SURUM } from "@/lib/surum";
|
||||
|
||||
/**
|
||||
@@ -7,20 +8,25 @@ import { SURUM } from "@/lib/surum";
|
||||
* (`bg-indigo-500`, §2.2 istisnası). Sürüm rozetin içinde değil, yanında
|
||||
* küçük puntoyla; dar ekranda yalnız rozet kalır. Logo fade'iyle birlikte
|
||||
* söner (NavBaslik yuvası açılınca), o yüzden site-header'daki fade
|
||||
* sınıflarını aynen taşır.
|
||||
* sınıflarını aynen taşır. Rozet /surum-notlari'na bağlantıdır (beta
|
||||
* açıklaması, sürüm günlüğü, geri bildirim adresi).
|
||||
*/
|
||||
export function SurumRozeti({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<span className={`inline-flex shrink-0 items-center gap-1.5 ${className}`}>
|
||||
<span className="inline-flex h-[22px] items-center rounded-full bg-indigo-500 px-2 text-xs leading-none font-semibold tracking-[-0.01em] whitespace-nowrap text-white">
|
||||
<Link
|
||||
href="/surum-notlari"
|
||||
aria-label={`Beta sürüm notları, sürüm ${SURUM}`}
|
||||
className={`group/rozet inline-flex shrink-0 items-center gap-1.5 rounded-full outline-none focus-visible:ring-3 focus-visible:ring-ring/50 ${className}`}
|
||||
>
|
||||
<span className="inline-flex h-[22px] items-center rounded-full bg-indigo-500 px-2 text-xs leading-none font-semibold tracking-[-0.01em] whitespace-nowrap text-white transition-opacity duration-150 group-hover/rozet:opacity-85">
|
||||
Beta
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="hidden text-xs leading-none font-medium tracking-[-0.01em] text-slate-500 tabular-nums sm:inline"
|
||||
aria-label={`Sürüm ${SURUM}`}
|
||||
>
|
||||
v{SURUM}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
54
src/components/tema-ikonu.tsx
Normal file
54
src/components/tema-ikonu.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useId } from "react";
|
||||
|
||||
// Tema seçicinin ikonları (DESIGN.md §2.3): tema geçişinde header'daki logonun
|
||||
// dönüştüğü biçimlerin durağan hâli (animasyonlu-mark.tsx `temaSekli`).
|
||||
// - "light": güneş — turuncu daire, arkasında şeftali hale.
|
||||
// - "dark": ay — şeftali hilal, iki ucunun ortasında mavi bir yıldız.
|
||||
// - "system": logonun üç karesi — ikisine de dönüşebilen hâl.
|
||||
// Renkler logonun renkleridir ve iki temada aynıdır (§2.3); 180'lik kutu
|
||||
// public/kolay-tercih-mark.svg ile aynı. Dekoratiftir: anlamı yanındaki
|
||||
// etiket taşır.
|
||||
|
||||
const MAVI = "#3b82f6";
|
||||
const SEFTALI = "#fdc7a7";
|
||||
const TURUNCU = "#ff5a00";
|
||||
|
||||
export type TemaIkonuTuru = "light" | "dark" | "system";
|
||||
|
||||
export function TemaIkonu({
|
||||
tur,
|
||||
className,
|
||||
}: {
|
||||
tur: TemaIkonuTuru;
|
||||
className?: string;
|
||||
}) {
|
||||
const maskeId = useId();
|
||||
return (
|
||||
<svg viewBox="0 0 180 180" aria-hidden="true" className={className}>
|
||||
{tur === "light" ? (
|
||||
<>
|
||||
<circle cx="90" cy="90" r="84" fill={SEFTALI} opacity="0.55" />
|
||||
<circle cx="90" cy="90" r="43" fill={TURUNCU} />
|
||||
</>
|
||||
) : tur === "dark" ? (
|
||||
<>
|
||||
{/* Hilal: daireden, sağa-yukarı kaydırılmış ikinci daire çıkarılır
|
||||
(sırtı solda). */}
|
||||
<mask id={maskeId}>
|
||||
<rect width="180" height="180" fill="#fff" />
|
||||
<circle cx="116" cy="76" r="66" fill="#000" />
|
||||
</mask>
|
||||
<circle cx="76" cy="92" r="74" fill={SEFTALI} mask={`url(#${maskeId})`} />
|
||||
{/* Yıldız: hilalin iki sivri ucunun tam ortası. */}
|
||||
<circle cx="108" cy="79" r="13" fill={MAVI} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<rect x="27" y="56" width="57" height="57" rx="13" fill={MAVI} />
|
||||
<rect x="91" y="29" width="56" height="56" rx="13" fill={SEFTALI} />
|
||||
<rect x="91" y="95" width="56" height="56" rx="13" fill={TURUNCU} />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
38
src/components/tema-kisayolu.tsx
Normal file
38
src/components/tema-kisayolu.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { temayiGecisle } from "@/lib/tema-gecisi";
|
||||
|
||||
/** Tuş yazı alanında basıldıysa kısayol değildir. */
|
||||
function yaziAlanindaMi(hedef: EventTarget | null) {
|
||||
if (!(hedef instanceof HTMLElement)) return false;
|
||||
if (hedef.isContentEditable) return true;
|
||||
const etiket = hedef.tagName;
|
||||
return etiket === "INPUT" || etiket === "TEXTAREA" || etiket === "SELECT";
|
||||
}
|
||||
|
||||
/**
|
||||
* Klavye kısayolu: `D` açık ↔ koyu temayı değiştirir (ipucu footer'daki
|
||||
* TemaSecici'nin altında, Kbd ile). Değiştirici tuşla (⌘/Ctrl/Alt) ya da yazı
|
||||
* alanındayken çalışmaz; "sistem" seçiliyken o anki çözümlenmiş temanın
|
||||
* tersine, açık bir seçime geçer. Geçiş logodan yumuşak bir halka gibi yayılır.
|
||||
*/
|
||||
export function TemaKisayolu() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const dinle = (e: KeyboardEvent) => {
|
||||
if (e.key !== "d" && e.key !== "D") return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey || e.repeat) return;
|
||||
if (yaziAlanindaMi(e.target)) return;
|
||||
e.preventDefault();
|
||||
const hedef = resolvedTheme === "dark" ? "light" : "dark";
|
||||
temayiGecisle(() => setTheme(hedef));
|
||||
};
|
||||
window.addEventListener("keydown", dinle);
|
||||
return () => window.removeEventListener("keydown", dinle);
|
||||
}, [resolvedTheme, setTheme]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { ThemeProvider, useTheme } from "next-themes";
|
||||
import { TemaKisayolu } from "@/components/tema-kisayolu";
|
||||
|
||||
// Gövde zemini (bg-slate-50) — açık ve koyu temadaki karşılığı
|
||||
const ZEMIN = { light: "#f8fafc", dark: "#080e19" };
|
||||
@@ -25,7 +26,8 @@ function AdresCubuguRengi() {
|
||||
* Tema (DESIGN.md §2.3): <html>'e `dark` sınıfı yazılır, renkler
|
||||
* globals.css'teki `.dark` token eşlemesinden gelir. Varsayılan açık tema;
|
||||
* kullanıcı footer'daki TemaSecici ile koyu ya da "sistem"e geçer, seçim
|
||||
* tarayıcıda saklanır.
|
||||
* tarayıcıda saklanır; `D` tuşu açık ↔ koyu geçişi yapar (TemaKisayolu).
|
||||
* Tema değişimi logodan yumuşak bir halka gibi yayılır (lib/tema-gecisi.ts).
|
||||
*/
|
||||
export function TemaSaglayici({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -37,6 +39,7 @@ export function TemaSaglayici({ children }: { children: React.ReactNode }) {
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<AdresCubuguRengi />
|
||||
<TemaKisayolu />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Monitor, Moon, Sun } from "lucide-react";
|
||||
import { TemaIkonu } from "@/components/tema-ikonu";
|
||||
import { Kbd } from "@/components/ui/kbd";
|
||||
import { temayiGecisle } from "@/lib/tema-gecisi";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SECENEKLER = [
|
||||
{ deger: "light", etiket: "Açık", ikon: Sun },
|
||||
{ deger: "dark", etiket: "Koyu", ikon: Moon },
|
||||
{ deger: "system", etiket: "Sistem", ikon: Monitor },
|
||||
{ deger: "light", etiket: "Açık" },
|
||||
{ deger: "dark", etiket: "Koyu" },
|
||||
{ deger: "system", etiket: "Sistem" },
|
||||
] as const;
|
||||
|
||||
const aboneOlma = () => () => {};
|
||||
@@ -16,7 +18,9 @@ const aboneOlma = () => () => {};
|
||||
/**
|
||||
* Tema seçici: açık / koyu / sistem (DESIGN.md §2.3). Seçili tema yalnızca
|
||||
* tarayıcıda bilinir; sunucu HTML'inde hiçbir seçenek işaretli gelmez,
|
||||
* hydration sonrası işaretlenir (yerleşim oynamaz).
|
||||
* hydration sonrası işaretlenir (yerleşim oynamaz). İkonlar geçişte logonun
|
||||
* dönüştüğü biçimlerdir: güneş, ay ve logonun kendisi (tema-ikonu.tsx). Seçim logodan yumuşak bir halka
|
||||
* gibi yayılarak uygulanır (lib/tema-gecisi.ts); altında `D` kısayolu ipucu durur.
|
||||
*/
|
||||
export function TemaSecici({ className }: { className?: string }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
@@ -27,15 +31,13 @@ export function TemaSecici({ className }: { className?: string }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center gap-2", className)}>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Tema"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border border-slate-200 bg-card p-1",
|
||||
className,
|
||||
)}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-card p-1"
|
||||
>
|
||||
{SECENEKLER.map(({ deger, etiket, ikon: Ikon }) => {
|
||||
{SECENEKLER.map(({ deger, etiket }) => {
|
||||
const secili = tarayicida && theme === deger;
|
||||
return (
|
||||
<button
|
||||
@@ -43,7 +45,7 @@ export function TemaSecici({ className }: { className?: string }) {
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={secili}
|
||||
onClick={() => setTheme(deger)}
|
||||
onClick={() => temayiGecisle(() => setTheme(deger))}
|
||||
className={cn(
|
||||
"inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-full px-3 text-sm font-semibold transition-[background-color,color,transform] duration-150 outline-none focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.97]",
|
||||
secili
|
||||
@@ -51,11 +53,16 @@ export function TemaSecici({ className }: { className?: string }) {
|
||||
: "text-slate-600 hover:text-orange-500",
|
||||
)}
|
||||
>
|
||||
<Ikon className="size-4" aria-hidden />
|
||||
<TemaIkonu tur={deger} className="size-[18px]" />
|
||||
{etiket}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<Kbd>D</Kbd>
|
||||
<span>ile açık / koyu arasında geç</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
// Sürüm notları sayfasının altındaki geri bildirim kutusu: başlık, metin
|
||||
// alanı, buton. Mesaj geriBildirimGonder sunucu aksiyonuyla destek kutusuna
|
||||
// e-posta olarak gider.
|
||||
|
||||
import { useState } from "react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { TypingAnimation } from "@/components/ui/typing-animation";
|
||||
import { geriBildirimGonder } from "@/features/geri-bildirim/geri-bildirim-actions";
|
||||
|
||||
const BASLIK = "Bir hata mı gördün?";
|
||||
const DEVAM = "Yalın üretim metodunu benimsiyor ve fikirlerine önem veriyoruz.";
|
||||
|
||||
export function GeriBildirimFormu({ className = "" }: { className?: string }) {
|
||||
const [mesaj, setMesaj] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function gonder(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPending(true);
|
||||
try {
|
||||
const sonuc = await geriBildirimGonder(mesaj);
|
||||
if (sonuc.ok) {
|
||||
toast.success("Geri bildirimin ulaştı, teşekkürler.");
|
||||
setMesaj("");
|
||||
} else {
|
||||
toast.error(sonuc.error);
|
||||
}
|
||||
} catch {
|
||||
toast.error("Beklenmeyen bir hata oluştu, tekrar dener misin?");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Dış kart yok: başlık sayfadaki sürüm başlıklarıyla aynı ölçekte durur,
|
||||
// altında tek yüzey var — metin alanı kenarlıksız, buton aynı yüzeyin
|
||||
// içinde (sohbet kutusuyla aynı kalıp, DESIGN.md §4.6).
|
||||
return (
|
||||
<section aria-labelledby="geri-bildirim-baslik" className={className}>
|
||||
<form onSubmit={gonder}>
|
||||
{/* Başlığın başı sabit; devamı bir kez daktilo gibi yazılır ve kalır
|
||||
(döngü yok). Görünmez tam metin aynı hücrede yer tutar ki yazı
|
||||
ilerlerken kutu aşağı kaymasın. Ekran okuyucu tam metni okur. */}
|
||||
<h2
|
||||
id="geri-bildirim-baslik"
|
||||
className="grid font-heading text-2xl font-bold text-balance text-slate-900 sm:text-3xl"
|
||||
>
|
||||
<span className="sr-only">
|
||||
{BASLIK} {DEVAM}
|
||||
</span>
|
||||
<span aria-hidden className="invisible col-start-1 row-start-1">
|
||||
{BASLIK} {DEVAM}
|
||||
</span>
|
||||
<span aria-hidden className="col-start-1 row-start-1">
|
||||
{BASLIK}{" "}
|
||||
<TypingAnimation
|
||||
typeSpeed={40}
|
||||
delay={400}
|
||||
className="inline leading-[inherit] tracking-normal text-slate-500"
|
||||
>
|
||||
{DEVAM}
|
||||
</TypingAnimation>
|
||||
</span>
|
||||
</h2>
|
||||
<div className="mt-5 rounded-2xl border border-slate-200 bg-card p-2 transition-colors duration-150 focus-within:border-primary focus-within:ring-3 focus-within:ring-ring/50">
|
||||
<Textarea
|
||||
id="geri-bildirim-mesaj"
|
||||
aria-labelledby="geri-bildirim-baslik"
|
||||
value={mesaj}
|
||||
onChange={(e) => setMesaj(e.target.value)}
|
||||
maxLength={2000}
|
||||
placeholder="Çekinme, aklına geleni yaz…"
|
||||
disabled={pending}
|
||||
className="min-h-28 resize-none border-0 bg-transparent px-3 py-2 focus-visible:ring-0 disabled:bg-transparent"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={pending || mesaj.trim().length < 10}
|
||||
className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600 active:scale-[0.97]"
|
||||
>
|
||||
{pending ? "Gönderiliyor…" : "Gönder"}
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
82
src/features/geri-bildirim/geri-bildirim-actions.ts
Normal file
82
src/features/geri-bildirim/geri-bildirim-actions.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
"use server";
|
||||
|
||||
import { headers } from "next/headers";
|
||||
import { getSession } from "@/lib/session";
|
||||
import { geriBildirimEpostasi } from "@/lib/eposta";
|
||||
import { epostaGonder } from "@/lib/eposta-gonder";
|
||||
import { SURUM } from "@/lib/surum";
|
||||
|
||||
const DESTEK_ADRESI = "destek@kolaytercih.com";
|
||||
const MIN_UZUNLUK = 10;
|
||||
const MAX_UZUNLUK = 2000;
|
||||
|
||||
// Uç oturumsuz da çalışır; kötüye kullanım yalnız kendi destek kutumuzu ve
|
||||
// Resend kotasını doldurur. Tek süreçte bellek içi sayaç bunu sınırlamaya
|
||||
// yeter: IP başına 10 dakikada 3 mesaj.
|
||||
const PENCERE_MS = 10 * 60 * 1000;
|
||||
const PENCERE_LIMIT = 3;
|
||||
const sonGonderimler = new Map<string, number[]>();
|
||||
|
||||
function sinirAsildi(ip: string): boolean {
|
||||
const simdi = Date.now();
|
||||
const taze = (sonGonderimler.get(ip) ?? []).filter(
|
||||
(t) => simdi - t < PENCERE_MS,
|
||||
);
|
||||
if (taze.length >= PENCERE_LIMIT) {
|
||||
sonGonderimler.set(ip, taze);
|
||||
return true;
|
||||
}
|
||||
taze.push(simdi);
|
||||
sonGonderimler.set(ip, taze);
|
||||
// Harita sınırsız büyümesin: eskimiş kayıtları ara sıra süpür.
|
||||
if (sonGonderimler.size > 1000) {
|
||||
for (const [anahtar, zamanlar] of sonGonderimler) {
|
||||
if (zamanlar.every((t) => simdi - t >= PENCERE_MS)) {
|
||||
sonGonderimler.delete(anahtar);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function geriBildirimGonder(
|
||||
hamMesaj: string,
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const mesaj = typeof hamMesaj === "string" ? hamMesaj.trim() : "";
|
||||
if (mesaj.length < MIN_UZUNLUK) {
|
||||
return { ok: false, error: "Biraz daha ayrıntı yazar mısın?" };
|
||||
}
|
||||
if (mesaj.length > MAX_UZUNLUK) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Mesaj en fazla ${MAX_UZUNLUK} karakter olabilir.`,
|
||||
};
|
||||
}
|
||||
|
||||
const [session, h] = await Promise.all([getSession(), headers()]);
|
||||
const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "bilinmiyor";
|
||||
if (sinirAsildi(ip)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Kısa sürede çok mesaj gönderdin. Birkaç dakika sonra tekrar dene.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await epostaGonder(
|
||||
DESTEK_ADRESI,
|
||||
geriBildirimEpostasi({
|
||||
mesaj,
|
||||
gonderen: session?.user.email ?? null,
|
||||
surum: SURUM,
|
||||
}),
|
||||
);
|
||||
return { ok: true };
|
||||
} catch (hata) {
|
||||
console.error("[geri-bildirim] Gönderilemedi:", hata);
|
||||
return {
|
||||
ok: false,
|
||||
error: `Mesajın gönderilemedi. ${DESTEK_ADRESI} adresine yazabilirsin.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,10 @@ import { authClient } from "@/lib/auth-client";
|
||||
import { olay, siraKovasi } from "@/lib/analitik";
|
||||
import { onbellekliJsonGetir } from "@/lib/istek-cache";
|
||||
import {
|
||||
profilDuzenlemeyiAc,
|
||||
useTercihProfili,
|
||||
} from "@/features/liste/hooks/use-tercih-profili";
|
||||
import type { SihirbazFacetleri } from "@/types/yokatlas";
|
||||
import { PUAN_TURU_ETIKET, sonucHref, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { sonucHref, type SihirbazSecimleri } from "@/features/sihirbaz/sihirbaz-sabitler";
|
||||
import { tercihProfiliYaz } from "@/features/sihirbaz/sihirbaz-profil";
|
||||
|
||||
// lib/db sunucu tarafı; etiketler client bundle'a sızmasın diye burada.
|
||||
@@ -105,8 +104,7 @@ export function HeroForm() {
|
||||
}
|
||||
|
||||
// Kayıtlı profil varsa sihirbazı atla, doğrudan sonuç sayfasına götür.
|
||||
// "Farklı sıralama gir" navbar'daki Değiştir ile aynı düzenleme modalını
|
||||
// (TercihProfiliKapisi, layout'ta monte) açar.
|
||||
// Sıralamayı değiştirmek navbar'daki Değiştir'den yapılır.
|
||||
if (kayitliProfil) {
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-3">
|
||||
@@ -120,18 +118,6 @@ export function HeroForm() {
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-sm text-slate-600">
|
||||
{kayitliProfil.sira.toLocaleString("tr-TR")} ·{" "}
|
||||
{PUAN_TURU_ETIKET[kayitliProfil.tur] ?? kayitliProfil.tur} sıralamanla
|
||||
devam edersin.{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={profilDuzenlemeyiAc}
|
||||
className="cursor-pointer font-medium text-slate-900 underline underline-offset-2 hover:text-slate-700"
|
||||
>
|
||||
Farklı sıralama gir
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -211,3 +211,39 @@ export function krediHatirlatmaEpostasi(): EpostaIcerik {
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sürüm notları sayfasındaki geri bildirim kutusundan gelen mesaj — destek
|
||||
* kutusuna düşer, kullanıcıya gitmez. `gonderen` oturum varsa hesabın
|
||||
* e-postasıdır; yoksa mesaj anonimdir.
|
||||
*/
|
||||
export function geriBildirimEpostasi({
|
||||
mesaj,
|
||||
gonderen,
|
||||
surum,
|
||||
}: {
|
||||
mesaj: string;
|
||||
gonderen: string | null;
|
||||
surum: string;
|
||||
}): EpostaIcerik {
|
||||
const konu = `Geri bildirim (v${surum})`;
|
||||
const kimden = gonderen ?? "Anonim (oturum yok)";
|
||||
const govde = `
|
||||
<h1 style="margin:0 0 12px;font-family:${BASLIK_TIPI};font-size:22px;line-height:1.35;font-weight:700;color:${LACIVERT};">
|
||||
Yeni geri bildirim
|
||||
</h1>
|
||||
<p style="margin:0 0 16px;font-size:13px;">
|
||||
Gönderen: <strong style="color:${LACIVERT};">${kacir(kimden)}</strong> · v${kacir(surum)}
|
||||
</p>
|
||||
<p style="margin:0;white-space:pre-wrap;color:${LACIVERT};">${kacir(mesaj)}</p>`;
|
||||
|
||||
return {
|
||||
subject: konu,
|
||||
html: kabuk({
|
||||
onizleme: kacir(mesaj.slice(0, 90)),
|
||||
govde,
|
||||
baslikEtiketi: konu,
|
||||
}),
|
||||
text: [`Gönderen: ${kimden} · v${surum}`, "", mesaj].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
100
src/lib/tema-gecisi.ts
Normal file
100
src/lib/tema-gecisi.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// Tema geçişi (DESIGN.md §2.3): açık ↔ koyu değişimi ekranı bir anda
|
||||
// çevirmez; yeni tema header'daki KolayTercih işaretinden yumuşak bir halka
|
||||
// gibi açılır — kenarı keskin değil, geniş ve tüylü; ışık yayılır gibi.
|
||||
// Geçiş kısa ve hafiftir; BİTİNCE logonun kareleri yeni temanın biçimine
|
||||
// dönüşür (koyuya: ay ve yıldız; açığa: güneş), kısa bir an durur ve
|
||||
// yeniden logoya toplanır.
|
||||
// Markanın yumuşak, oyunsu diline uysun diye sert kenar, piksel ya da ikinci
|
||||
// bir şekil yok (9 Eki 2026).
|
||||
//
|
||||
// Nasıl: View Transitions API eski/yeni görüntü çiftini verir; yeni görüntü
|
||||
// radyal degrade maskesiyle açılır. Yarıçapı CSS sürer (globals.css
|
||||
// `.kt-tema-gecis`), buradan yalnız merkez ve son yarıçap verilir. Tarayıcı
|
||||
// View Transitions bilmiyorsa ya da hareket azaltılmışsa tema düz değişir.
|
||||
|
||||
/** Geçiş sırasında <html>'de duran sınıf (globals.css). */
|
||||
const GECIS_SINIFI = "kt-tema-gecis";
|
||||
|
||||
/** Geçiş bitince pencereye atılan olay; header'daki logo bunu dinleyip
|
||||
* karelerini güneşe ya da aya dönüştürür (animasyonlu-mark.tsx). */
|
||||
export const TEMA_GECIS_OLAYI = "kt-tema-gecis";
|
||||
|
||||
// Maskenin tam opak çekirdeği yarıçapın bu oranında biter, kalanı tüydür
|
||||
// (globals.css'teki --kt-tema-cekirdek ile aynı olmalı): son yarıçap, çekirdek
|
||||
// en uzak köşeyi de örtecek kadar büyük seçilir.
|
||||
const CEKIRDEK = 0.55;
|
||||
|
||||
/** Sayfa geçişi ile logo dönüşümü arasındaki kısa nefes (ms). */
|
||||
const LOGO_NEFESI = 80;
|
||||
|
||||
let suruyor = false;
|
||||
|
||||
/**
|
||||
* `uygula` temayı değiştiren çağrıdır (ör. `setTheme("dark")`). Geçiş
|
||||
* sürerken gelen istek yok sayılır: art arda D'ye basınca ekrandaki görüntü
|
||||
* ile tema durumu birbirine girmesin.
|
||||
*/
|
||||
export function temayiGecisle(uygula: () => void) {
|
||||
if (typeof document === "undefined") {
|
||||
uygula();
|
||||
return;
|
||||
}
|
||||
const azHareket = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (azHareket) {
|
||||
uygula();
|
||||
return;
|
||||
}
|
||||
if (!document.startViewTransition) {
|
||||
// Sayfa geçişi yok ama logo yine dönüşür; temanın sınıfı <html>'e
|
||||
// yazılsın diye kısa bir an beklenir.
|
||||
uygula();
|
||||
window.setTimeout(
|
||||
() => window.dispatchEvent(new Event(TEMA_GECIS_OLAYI)),
|
||||
LOGO_NEFESI,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (suruyor) return;
|
||||
|
||||
const kok = document.documentElement;
|
||||
// Halkanın merkezi header'daki logo; krom gizli sayfalarda sol üst köşe.
|
||||
const logo = document
|
||||
.querySelector('header a[aria-label="KolayTercih"]')
|
||||
?.getBoundingClientRect();
|
||||
const gorunur = logo && logo.width > 0 && logo.bottom > 0;
|
||||
const x = gorunur ? logo.left + logo.height / 2 : 24;
|
||||
const y = gorunur ? logo.top + logo.height / 2 : 24;
|
||||
const enUzak = Math.hypot(
|
||||
Math.max(x, window.innerWidth - x),
|
||||
Math.max(y, window.innerHeight - y),
|
||||
);
|
||||
|
||||
kok.style.setProperty("--kt-tema-x", `${x}px`);
|
||||
kok.style.setProperty("--kt-tema-y", `${y}px`);
|
||||
kok.style.setProperty("--kt-tema-son", `${Math.ceil(enUzak / CEKIRDEK)}px`);
|
||||
kok.classList.add(GECIS_SINIFI);
|
||||
suruyor = true;
|
||||
|
||||
const gecis = document.startViewTransition(uygula);
|
||||
// Sekme gizliyken / yeniden boyutlanırken tarayıcı geçişi iptal eder:
|
||||
// `ready` reddedilir, tema yine uygulanmıştır; konsola hata düşmesin.
|
||||
gecis.ready.catch(() => {});
|
||||
gecis.finished
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
kok.classList.remove(GECIS_SINIFI);
|
||||
kok.style.removeProperty("--kt-tema-x");
|
||||
kok.style.removeProperty("--kt-tema-y");
|
||||
kok.style.removeProperty("--kt-tema-son");
|
||||
suruyor = false;
|
||||
// Sıra: önce sayfa geçişi biter, sonra logo dönüşür — ikisi üst üste
|
||||
// binmez, göz önce temaya sonra logoya gider. Kısa nefes (LOGO_NEFESI):
|
||||
// geçiş iptal olduysa (sekme gizli vb.) `finished` hemen döner ve
|
||||
// temanın sınıfı <html>'e henüz yazılmamış olabilir; logo yanlış
|
||||
// biçime dönüşmesin.
|
||||
window.setTimeout(
|
||||
() => window.dispatchEvent(new Event(TEMA_GECIS_OLAYI)),
|
||||
LOGO_NEFESI,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user