From 40a1c8ef8e4895a9523a8324218eeae3a20ac835 Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Sun, 26 Jul 2026 15:26:40 +0300 Subject: [PATCH] =?UTF-8?q?Dev=20paneli=20Apple=20hareket=20diliyle=20yeni?= =?UTF-8?q?lendi;=20animasyon=20ve=20UX=20d=C3=BCzeltmeleri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dev paneli: DEV çipinden layoutId morph ile büyüyüp küçülür (kritik sönümlü spring), yarı saydam malzeme (backdrop-blur + saturate), basışta anında scale geri bildirimi, Escape ile kapanış, reduced-motion/transparency desteği - TypingAnimation ve TextLoop: prefers-reduced-motion'da sonsuz döngü durur, ilk metin sabit gösterilir - kt-unblur: reduced-motion'da blur çözülmesi yerine kısa yumuşak belirme - Sihirbaz: reduced-motion'da adım geçişinde height/x anında oturur; adım noktalarında transition-all yerine kapsamlı geçiş özellikleri - Sihirbaz il adımındaki arama kutusu kaldırıldı; tüm iller program sayısına göre sıralı çip olarak listelenir - /giris: spinner yerine kartın iskeletini gösteren route-level Skeleton Co-Authored-By: Claude Fable 5 --- data/app.db | Bin 135168 -> 135168 bytes src/app/giris/loading.tsx | 39 +++ src/app/globals.css | 17 ++ src/components/core/text-loop.tsx | 7 +- src/components/dev/dev-panel.tsx | 391 +++++++++++++++---------- src/components/sihirbaz-adimlar.tsx | 97 +++--- src/components/ui/typing-animation.tsx | 11 +- 7 files changed, 338 insertions(+), 224 deletions(-) create mode 100644 src/app/giris/loading.tsx diff --git a/data/app.db b/data/app.db index a72736c2fed11668c51ee574dd28d7feedda885c..997a8f54c3b8b317689167e59dd0b2dd0104591f 100644 GIT binary patch delta 405 zcmZozz|pXPV}dl}_K7mijN2O%wk9xc=4Z6nEGY1gW4e$eqgr5-5C@kpi*Iyexi1S# zRYYF0Q(Asms$XSjnt4@tMunGwZ=O$3QF%(CsZ&uwR=x>uYD#fdV78%AXh3p~muF~h zNu_CJnOmZ_nSqgWSV(2bbVqqcrRfKx7^|kMN--+2{p`(3{n@+SSBmi{D-+*8pw+MV zG*y^=83|d&0a7$QN{UfoySqN)ZhlolHbXSjfi>7O_E>urn)~IN`8heKxi}U%J4KcH z`ehV*`<3`cre>D7nngO7SNet}1-d2{yXU2OdVBfjB!?CU6?m37heefmWqB4>E2ZWp zX67haDNS$mV-(?2s?AFMwFBs#9uVGcU(I-hg;5#kBYu7rkYfqix81#;ac2Vnjhu#O delta 374 zcmZozz|pXPV}dl}=7}=SjGG%1wk9xc=4TYzEGY1gW4e$eqndV+Wll+PN=jC~Y`AZF zn3G?HiLs$ypi#a>aJpr%NmP`nOGKt$X0T~MMZ|PRc}At_2c#Hd`EwFWQ&RN}^^8mm z42|?Gr)SDBDl?i-7nNc(VtaHwEA`R!?Y>fsM_HNpJ%Dz-;$u-^_GO$dFUcs$1GSI? zED|NfsIc8#pK&+8Dk0k;8tT9r>=}Eky?mkT?&JO z^TX490)o7~Q+$&PbCNQ%l5+zxvx_{U+>#1&Q_9P{^76fW)2o$Ia}zUjl&qAdH~KM( t@F~@1r9S=wbo&Vq-fmybc!g!VumR(1zR3y#swyD&60&l;dq3mO1^`B*cV7Si diff --git a/src/app/giris/loading.tsx b/src/app/giris/loading.tsx new file mode 100644 index 0000000..b1a2221 --- /dev/null +++ b/src/app/giris/loading.tsx @@ -0,0 +1,39 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +// /giris açılırken spinner yerine sayfanın kendi iskeleti: kart, başlık, +// Google butonu, ayraç, e-posta alanı ve gönder butonu aynı yerde belirir — +// içerik gelince zıplama olmaz. +export default function GirisLoading() { + return ( +
+
+
+ {/* Başlık + alt metin */} + + + + + {/* Google ile devam et */} + + + {/* "veya" ayracı */} +
+ + + +
+ + {/* E-posta alanı + gönder butonu */} + + +
+ + {/* Kart altındaki yasal metin */} +
+ + +
+
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index e1393af..d569ac0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -144,6 +144,23 @@ animation: kt-unblur 0.9s ease-out both; } +/* Hareket azaltılmışsa blur çözülmesi yerine kısa, yumuşak bir belirme: + kavramayı sağlayan geri bildirim kalır, gösterişli efekt düşer. */ +@media (prefers-reduced-motion: reduce) { + .kt-unblur { + animation: kt-unblur-sade 0.25s ease-out both; + } +} + +@keyframes kt-unblur-sade { + from { + opacity: 0.6; + } + to { + opacity: 1; + } +} + @layer base { * { @apply border-border outline-ring/50; diff --git a/src/components/core/text-loop.tsx b/src/components/core/text-loop.tsx index 346d3ec..876d82a 100644 --- a/src/components/core/text-loop.tsx +++ b/src/components/core/text-loop.tsx @@ -6,6 +6,7 @@ import { cn } from "@/lib/utils"; import { AnimatePresence, motion, + useReducedMotion, type Transition, type Variants, } from "motion/react"; @@ -36,8 +37,12 @@ export function TextLoop({ }: TextLoopProps) { const [currentIndex, setCurrentIndex] = useState(0); const items = Children.toArray(children); + // Sürekli dönen dekoratif metin: hareket azaltılsın diyen kullanıcıda + // döngü hiç başlamaz, ilk metin sabit kalır. + const azMotion = useReducedMotion(); useEffect(() => { + if (azMotion) return; const intervalMs = interval * 1000; const timer = setInterval(() => { setCurrentIndex((current) => { @@ -47,7 +52,7 @@ export function TextLoop({ }); }, intervalMs); return () => clearInterval(timer); - }, [items.length, interval, onIndexChange]); + }, [azMotion, items.length, interval, onIndexChange]); return (
diff --git a/src/components/dev/dev-panel.tsx b/src/components/dev/dev-panel.tsx index d01c2dd..0908530 100644 --- a/src/components/dev/dev-panel.tsx +++ b/src/components/dev/dev-panel.tsx @@ -4,10 +4,16 @@ // (layout.tsx NODE_ENV kontrolüyle monte eder; aksiyonlar da sunucuda // ayrıca korunur). Amaç: satın alma / giriş / kredi durumlarını tek // tıkla taklit edip uygulamanın tüm sayfalarını gerçek akışta gezmek. +// +// Hareket dili (Apple "Designing Fluid Interfaces"): panel DEV çipinden +// büyür ve yine ona küçülür (paylaşılan layoutId ile mekânsal bağ), yay +// kritik sönümlüdür (bounce 0) — açılış ortasında kapatılırsa mevcut +// değerden geri döner. Yüzey opak kutu değil, yarı saydam malzemedir. -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { Loader2, Wrench, X } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { toast } from "sonner"; import { authClient } from "@/lib/auth-client"; import { @@ -35,12 +41,26 @@ const SAYFALAR: { ad: string; href: string }[] = [ { ad: "404", href: "/boyle-bir-sayfa-yok" }, ]; +// Apple değerleri: damping 1.0 (bounce 0), response ~0.35 sn. +// Yay süreli değildir; hedef değişirse mevcut değer + hızdan devam eder. +const YAY = { type: "spring", bounce: 0, duration: 0.35 } as const; + +// Basış anında tepki (pointer-down feedback) — bırakınca değil. +const BASIS = + "active:scale-[0.97] transition-[transform,background-color,border-color,opacity] duration-150 ease-out"; + +// Yarı saydam malzeme; şeffaflık azaltılsın diyen kullanıcıya opak yüzey. +const MALZEME = + "border border-white/60 bg-white/80 shadow-2xl shadow-slate-900/20 backdrop-blur-2xl backdrop-saturate-150 " + + "[@media(prefers-reduced-transparency:reduce)]:bg-white [@media(prefers-reduced-transparency:reduce)]:backdrop-blur-none"; + export function DevPanel() { const [acik, setAcik] = useState(false); const [durum, setDurum] = useState(null); const [email, setEmail] = useState("test@kolaytercih.dev"); const [mesgul, setMesgul] = useState(false); const router = useRouter(); + const azMotion = useReducedMotion(); const yenile = useCallback(async () => { try { @@ -55,6 +75,16 @@ export function DevPanel() { void yenile(); } + // Yol bulma: panelden çıkış her an mümkün — Escape da kapatır. + useEffect(() => { + if (!acik) return; + const f = (e: KeyboardEvent) => { + if (e.key === "Escape") setAcik(false); + }; + window.addEventListener("keydown", f); + return () => window.removeEventListener("keydown", f); + }, [acik]); + // Her aksiyonu tek kalıptan geçir: meşgul kilidi + durum tazeleme + // server component'lerin (header'daki kredi rozeti dahil) yenilenmesi async function calistir(fn: () => Promise) { @@ -99,175 +129,216 @@ export function DevPanel() { } } - if (!acik) { - return ( - - ); - } - const g = durum?.girisli ? durum : null; - return ( -
-
-

- - Dev süperadmin - {mesgul ? ( - - ) : null} -

- -
+ // Azaltılmış hareket: morph yerine kısa çapraz solma (yaysız, kaymasız). + const paylasilan = azMotion ? {} : { layoutId: "dev-panel" }; + const gecis = azMotion ? { duration: 0.15 } : YAY; -
- {/* Oturum */} -
-

- Oturum -

- {g ? ( -
-

{g.email}

-

- {g.kredi} kredi · {g.hasPaket ? "paketli" : "paketsiz"} ·{" "} - {g.raporVar - ? `rapor var (${g.sira?.toLocaleString("tr-TR")} · ${g.tur})` - : "rapor yok"} + return ( + + {!acik ? ( + + + DEV + + ) : ( + + {/* İçerik morph sırasında bozulmasın: yüzey oturduktan hemen + sonra belirir (malzeme önce, içerik sonra). */} + + {/* Başlık: opak şerit değil, aynı malzemenin üstünde ince + bir kenar çizgisiyle ayrışan katman. */} +

+

+ + + + Dev süperadmin + {mesgul ? ( + + ) : null}

- ) : ( -

Girişsiz geziyorsun.

- )} -
- setEmail(e.target.value)} - placeholder="deneme@ornek.dev" - className="h-9 min-w-0 flex-1 rounded-lg border border-slate-200 px-2.5 text-xs outline-none focus-visible:border-violet-400" - /> - -
-

- Yeni e-posta = 5 kredili yeni kullanıcı (magic link atlanır). -

-
- {/* Senaryolar */} -
-

- Senaryo -

-
- {( - [ - { - ad: "Yeni kullanıcı", - aciklama: "5 kredi · paketsiz · raporsuz", - fn: async () => { - await devAyarla({ kredi: 5, hasPaket: false }); - await devRaporSil(); - }, - }, - { - ad: "Paketli", - aciklama: "60 kredi · paket aktif", - fn: () => devAyarla({ kredi: 60, hasPaket: true }), - }, - { - ad: "Kredisi bitmiş", - aciklama: "0 kredi", - fn: () => devAyarla({ kredi: 0 }), - }, - { - ad: "Raporu sil", - aciklama: "boş durumlar", - fn: () => devRaporSil(), - }, - ] as const - ).map((s) => ( - - ))} -
- -
+
+ {/* Oturum */} +
+

+ Oturum +

+ {g ? ( +
+

{g.email}

+

+ {g.kredi} kredi · {g.hasPaket ? "paketli" : "paketsiz"} ·{" "} + {g.raporVar + ? `rapor var (${g.sira?.toLocaleString("tr-TR")} · ${g.tur})` + : "rapor yok"} +

+ +
+ ) : ( +

+ Girişsiz geziyorsun. +

+ )} +
+ setEmail(e.target.value)} + placeholder="deneme@ornek.dev" + className="h-9 min-w-0 flex-1 rounded-lg border border-slate-900/10 bg-white/70 px-2.5 text-xs outline-none transition-colors duration-150 focus-visible:border-violet-400" + /> + +
+

+ Yeni e-posta = 5 kredili yeni kullanıcı (magic link atlanır). +

+
- {/* Sayfalar */} -
-

- Sayfalar -

-
- {SAYFALAR.map((s) => ( - - {s.ad} - - ))} -
-
-
-
+ {/* Senaryolar */} +
+

+ Senaryo +

+
+ {( + [ + { + ad: "Yeni kullanıcı", + aciklama: "5 kredi · paketsiz · raporsuz", + fn: async () => { + await devAyarla({ kredi: 5, hasPaket: false }); + await devRaporSil(); + }, + }, + { + ad: "Paketli", + aciklama: "60 kredi · paket aktif", + fn: () => devAyarla({ kredi: 60, hasPaket: true }), + }, + { + ad: "Kredisi bitmiş", + aciklama: "0 kredi", + fn: () => devAyarla({ kredi: 0 }), + }, + { + ad: "Raporu sil", + aciklama: "boş durumlar", + fn: () => devRaporSil(), + }, + ] as const + ).map((s) => ( + + ))} +
+ +
+ + {/* Sayfalar */} +
+

+ Sayfalar +

+
+ {SAYFALAR.map((s) => ( + + {s.ad} + + ))} +
+
+
+ + + )} + ); } diff --git a/src/components/sihirbaz-adimlar.tsx b/src/components/sihirbaz-adimlar.tsx index 0574bcc..bcec019 100644 --- a/src/components/sihirbaz-adimlar.tsx +++ b/src/components/sihirbaz-adimlar.tsx @@ -63,13 +63,13 @@ export function SihirbazAdimlar({ }) { const [adim, setAdim] = useState(0); const [yon, setYon] = useState(1); + const [gecisBasladi, setGecisBasladi] = useState(false); const [panelRef, panelOlculeri] = useMeasure(); const hareketiAzalt = useReducedMotion(); const [kategoriler, setKategoriler] = useState( baslangicSecimler?.kategoriler ?? [], ); const [iller, setIller] = useState(baslangicSecimler?.iller ?? []); - const [ilArama, setIlArama] = useState(""); const [universiteTipi, setUniversiteTipi] = useState( baslangicSecimler?.universiteTipi ?? "farketmez", ); @@ -98,9 +98,15 @@ export function SihirbazAdimlar({ function adimaGit(yeniAdim: number) { setYon(yeniAdim > adim ? 1 : -1); + setGecisBasladi(true); setAdim(yeniAdim); } + const panelYuksekligi = + gecisBasladi && panelOlculeri.height > 0 + ? panelOlculeri.height + : "auto"; + return (
@@ -109,10 +115,10 @@ export function SihirbazAdimlar({ key={i} className={ i === adim - ? "h-1.5 w-6 rounded-full bg-primary transition-all" + ? "h-1.5 w-6 rounded-full bg-primary transition-[width,background-color] duration-200 ease-out" : i < adim - ? "h-1.5 w-3 rounded-full bg-primary/50 transition-all" - : "h-1.5 w-1.5 rounded-full bg-slate-200 transition-all" + ? "h-1.5 w-3 rounded-full bg-primary/50 transition-[width,background-color] duration-200 ease-out" + : "h-1.5 w-1.5 rounded-full bg-slate-200 transition-[width,background-color] duration-200 ease-out" } /> ))} @@ -128,16 +134,14 @@ export function SihirbazAdimlar({ enter: (gecisYonu: number) => ({ x: hareketiAzalt ? 0 : gecisYonu > 0 ? "100%" : "-100%", opacity: 0, - height: - panelOlculeri.height > 0 ? panelOlculeri.height : "auto", + height: panelYuksekligi, position: "relative", }), center: { zIndex: 1, x: 0, opacity: 1, - height: - panelOlculeri.height > 0 ? panelOlculeri.height : "auto", + height: panelYuksekligi, }, exit: (gecisYonu: number) => ({ zIndex: 0, @@ -151,7 +155,13 @@ export function SihirbazAdimlar({ }} transition={ hareketiAzalt - ? { opacity: { duration: 0.12 } } + ? // Azaltılmış harekette yalnızca kısa çapraz solma; height/x + // varsayılan spring'e düşüp oynamasın diye açıkça sıfırlanır. + { + opacity: { duration: 0.12 }, + x: { duration: 0 }, + height: { duration: 0 }, + } : { x: { type: "spring", stiffness: 300, damping: 30 }, height: { type: "spring", stiffness: 300, damping: 30 }, @@ -159,7 +169,7 @@ export function SihirbazAdimlar({ } } > -
+

Hangi alanlara ilgi duyuyorsun?

@@ -187,56 +197,23 @@ export function SihirbazAdimlar({ En fazla 5 il seçebilirsin. İstersen boş bırak, fark etmez.

- {/* Birincil seçim yolu: arama + çipler (haritadaki küçük iller - mobilde güvenilir dokunma hedefi değil) */} - setIlArama(e.target.value)} - placeholder="İl ara (ör. İzmir)" - aria-label="İl ara" - className="mt-4 h-10 w-full rounded-lg border border-slate-200 bg-white px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" - /> - {(() => { - const q = ilArama.trim().toLocaleLowerCase("tr-TR"); - const sirali = [...facetler.iller].sort((a, b) => b.adet - a.adet); - const eslesen = q - ? sirali.filter((i) => - i.il.toLocaleLowerCase("tr-TR").includes(q), - ) - : sirali.slice(0, 12); - const kalan = q ? 0 : facetler.iller.length - eslesen.length; - return ( -
-
- {eslesen.map((i) => ( - listeDegistir(iller, setIller, i.il, 5)} - > - {i.il.toLocaleLowerCase("tr-TR")} - - {i.adet} - - - ))} - {eslesen.length === 0 ? ( -

- Bu sıralamada "{ilArama.trim()}" için program - bulunamadı. -

- ) : null} -
- {kalan > 0 ? ( -

- En çok programı olan 12 il gösteriliyor — diğer {kalan} il - için arama yaz. -

- ) : null} -
- ); - })()} + {/* Çipler birincil seçim yolu (haritadaki küçük iller mobilde + güvenilir dokunma hedefi değil); tüm iller program sayısına + göre sıralı listelenir. */} +
+ {[...facetler.iller] + .sort((a, b) => b.adet - a.adet) + .map((i) => ( + listeDegistir(iller, setIller, i.il, 5)} + > + {i.il.toLocaleLowerCase("tr-TR")} + {i.adet} + + ))} +
{iller.length > 0 ? (
diff --git a/src/components/ui/typing-animation.tsx b/src/components/ui/typing-animation.tsx index 7819f71..8641732 100644 --- a/src/components/ui/typing-animation.tsx +++ b/src/components/ui/typing-animation.tsx @@ -12,6 +12,7 @@ import { import { motion, useInView, + useReducedMotion, type DOMMotionComponents, type HTMLMotionProps, type MotionProps, @@ -84,6 +85,9 @@ export function TypingAnimation({ const [currentWordIndex, setCurrentWordIndex] = useState(0) const [currentCharIndex, setCurrentCharIndex] = useState(0) const [phase, setPhase] = useState<"typing" | "pause" | "deleting">("typing") + // Sürekli yazıp silen döngü, hareket azaltılsın diyen kullanıcı için + // rahatsız edici: ilk metin tam ve sabit gösterilir, imleç yanmaz. + const azMotion = useReducedMotion() const elementRef = useRef(null) const isInView = useInView(elementRef as RefObject, { amount: 0.3, @@ -117,7 +121,7 @@ export function TypingAnimation({ useEffect(() => { let timeout: ReturnType | null = null - if (shouldStart && wordsToAnimate.length > 0) { + if (!azMotion && shouldStart && wordsToAnimate.length > 0) { const timeoutDelay = delay > 0 && displayedText === "" ? delay @@ -175,6 +179,7 @@ export function TypingAnimation({ } } }, [ + azMotion, shouldStart, phase, currentCharIndex, @@ -225,8 +230,8 @@ export function TypingAnimation({ )} {...props} > - {displayedText} - {shouldShowCursor && ( + {azMotion ? (wordsToAnimate[0] ?? "") : displayedText} + {shouldShowCursor && !azMotion && (