Refactor code structure for improved readability and maintainability
All checks were successful
Deploy / deploy (push) Successful in 10m26s

This commit is contained in:
bilalgursen
2026-07-30 03:17:12 +03:00
parent 40a1c8ef8e
commit dc6d894a37
33 changed files with 1574 additions and 651 deletions

View File

@@ -12,7 +12,7 @@
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Wrench, X } from "lucide-react";
import { Wrench, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
@@ -184,8 +184,8 @@ export function DevPanel() {
</span>
Dev süperadmin
{mesgul ? (
<Loader2
className="size-3.5 animate-spin text-slate-400"
<span
className="size-2 animate-pulse rounded-full bg-slate-400"
aria-hidden
/>
) : null}

View File

@@ -2,7 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowRight, Loader2 } from "lucide-react";
import { ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -123,12 +123,16 @@ export function HeroForm() {
<Input
id="siralama"
inputMode="numeric"
autoComplete="off"
placeholder="YKS başarı sıralaman (ör. 85.000)"
value={siralama}
onChange={(e) => siralamaDegisti(e.target.value)}
aria-invalid={hata ? true : undefined}
aria-describedby={hata ? "siralama-hata" : undefined}
className="h-12 flex-1 bg-white text-base"
// flex-1 yalnızca yatay dizilimde (sm+): mobilde form flex-col olduğu
// için flex-1 inputun yüksekliğini eziyordu. Mobilde geniş padding
// + büyük yazı; sm ve üzeri eski görünüm.
className="h-12 bg-background px-5 text-lg sm:flex-1 sm:px-2.5 sm:text-base"
/>
<Button
type="submit"
@@ -136,11 +140,8 @@ export function HeroForm() {
disabled={yukleniyor}
className="h-12 cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600"
>
{yukleniyor ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : null}
Listemi oluştur
{yukleniyor ? null : <ArrowRight className="size-4" aria-hidden />}
{yukleniyor ? "Hazırlanıyor…" : "Listemi oluştur"}
<ArrowRight className="size-4" aria-hidden />
</Button>
</form>

View File

@@ -0,0 +1,38 @@
"use client";
import type { ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Landing bölümlerinin görünüme girerken yumuşakça belirmesi.
* Yalnızca transform/opacity animasyonu yapar; hareket azaltılmışsa
* kaydırma yerine sadece kısa bir belirme kalır.
*/
export function Reveal({
children,
delay = 0,
y = 28,
className,
}: {
children: ReactNode;
delay?: number;
y?: number;
className?: string;
}) {
const reduceMotion = useReducedMotion();
return (
<motion.div
className={className}
initial={{ opacity: 0, y: reduceMotion ? 0 : y }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "0px 0px -80px 0px" }}
transition={{
duration: reduceMotion ? 0.25 : 0.55,
delay,
ease: [0.21, 0.47, 0.32, 0.98],
}}
>
{children}
</motion.div>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
import { useRef, type ReactNode } from "react";
import {
motion,
useReducedMotion,
useScroll,
useTransform,
} from "motion/react";
/**
* Site geneli hafif scroll parallax'ı. Element görünümden geçerken
* scroll'a bağlı küçük bir dikey sürüklenme uygular (yalnızca transform,
* scroll ile 1:1 — gecikme/easing yok).
*
* strength > 0: içerik scroll'dan biraz hızlı akar (ön katman).
* strength < 0: içerik scroll'un gerisinde kalır (arka katman, derinlik).
*
* `fromStart`, sayfanın tepesinde yüklenen (hero gibi) elemanlar içindir:
* sayfa en üstteyken kayma tam 0'dır, aşağı indikçe birikir — böylece
* ilk boyamada içerik kaymış görünmez.
*
* Hareket azaltılmışsa (prefers-reduced-motion) hiç kayma uygulanmaz.
*/
export function Parallax({
children,
strength = 20,
fromStart = false,
className,
}: {
children: ReactNode;
/** Piksel cinsinden sürüklenme genliği; işaret katman yönünü belirler. */
strength?: number;
fromStart?: boolean;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const reduceMotion = useReducedMotion();
const { scrollYProgress } = useScroll({
target: ref,
offset: fromStart
? ["start start", "end start"]
: ["start end", "end start"],
});
const y = useTransform(
scrollYProgress,
[0, 1],
fromStart ? [0, -2 * strength] : [strength, -strength],
);
return (
<motion.div
ref={ref}
className={className}
// willChange: kayma 0'dan geçerken katmanın compositor'dan düşüp
// tekrar oluşturulmasını (raster churn) engeller
style={
reduceMotion ? { y: 0 } : { y, willChange: "transform" }
}
>
{children}
</motion.div>
);
}

View File

@@ -0,0 +1,256 @@
"use client";
import { useRef, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { usePixelHeat } from "@/components/use-pixel-heat";
/**
* Hero'daki dot-matrix dilini (TurkeyPixelMap) bölüm ayraçlarına ve
* dekorlara taşıyan piksel süsleri. Rastgelelik yerine deterministik
* hash kullanılır ki sunucu ve istemci aynı deseni üretsin.
*
* Hepsi hero'daki imleç ısı fırçasına katılır (usePixelHeat): imleç
* yaklaşınca pikseller turuncuya ısınır, uzaklaşınca yavaşça soğur.
* Turuncu vurgu (accent) pikselleri hero'daki şehirler gibi fırçaya
* katılmaz.
*/
// Hero ile aynı ızgara ölçüleri
const CELL = 10;
const DOT = 7.4;
const INSET = (CELL - DOT) / 2;
// Hero ile aynı doku paleti
const OPACITIES = [0.3, 0.16, 0.24, 0.12, 0.27, 0.19];
const opacityFor = (x: number, y: number) =>
OPACITIES[(x * 7 + y * 11) % OPACITIES.length];
// Deterministik sözde-rastgele: aynı girdi her ortamda aynı deseni verir
const hash = (x: number, y: number, seed: number) => {
const n = Math.sin(x * 127.1 + y * 311.7 + seed * 74.7) * 43758.5453;
return n - Math.floor(n);
};
function pixelRect(
x: number,
y: number,
extra: { key: string; accent?: boolean; accentDelay?: number },
) {
const shared = {
x: x * CELL + INSET,
y: y * CELL + INSET,
width: DOT,
height: DOT,
rx: 2,
};
if (extra.accent) {
return (
<rect
key={extra.key}
{...shared}
className="animate-pulse fill-orange-500/70 motion-reduce:animate-none"
style={{
animationDuration: "3.6s",
animationDelay: `${extra.accentDelay ?? 0}s`,
}}
/>
);
}
const op = opacityFor(x, y);
return (
<rect
key={extra.key}
{...shared}
data-px=""
data-cx={x * CELL + CELL / 2}
data-cy={y * CELL + CELL / 2}
data-op={op}
fill="currentColor"
fillOpacity={op}
/>
);
}
/**
* Geniş alanlar için seyrek piksel zemini (ör. koyu CTA bandı).
* İçeriğin oturduğu orta bölge tamamen boş bırakılır; pikseller
* kenarlara doğru yoğunlaşır.
*/
export function PixelField({
seed = 7,
className,
}: {
seed?: number;
className?: string;
}) {
const svgRef = useRef<SVGSVGElement>(null);
usePixelHeat(svgRef);
const COLS = 42;
const ROWS = 12;
const cx = (COLS - 1) / 2;
const cy = (ROWS - 1) / 2;
const maxD = Math.sqrt(cx * cx + cy * cy);
const pixels: ReactNode[] = [];
let accentIdx = 0;
// Hero serpintisiyle aynı kural: içeriğin oturduğu orta kolon
// (genişliğin %2278 bandı) tüm yükseklik boyunca tamamen boş kalır
const BAND_MIN = COLS * 0.22;
const BAND_MAX = COLS * 0.78;
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
if (x > BAND_MIN && x < BAND_MAX) continue;
// Kenarlara doğru artan doluluk
const d = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2) / maxD;
const density = 0.1 + 0.42 * d * d;
if (hash(x, y, seed) > density) continue;
const accent = hash(x, y, seed + 99) > 0.96;
pixels.push(
pixelRect(x, y, {
key: `${x}-${y}`,
accent,
accentDelay: accent ? (accentIdx++ % 8) * 0.45 : 0,
}),
);
}
}
return (
<svg
ref={svgRef}
viewBox={`0 0 ${COLS * CELL} ${ROWS * CELL}`}
preserveAspectRatio="xMidYMid slice"
aria-hidden="true"
focusable="false"
className={cn("h-full w-full", className)}
>
{pixels}
</svg>
);
}
/**
* Bölümler arasında sert border çizgileri yerine kullanılan, ortada
* yoğunlaşıp kenarlara doğru dağılan piksel ayraç. Hero'nun dot-matrix
* dilini bölüm geçişlerine taşır.
*/
export function PixelDivider({
seed = 5,
className,
}: {
seed?: number;
className?: string;
}) {
const svgRef = useRef<SVGSVGElement>(null);
usePixelHeat(svgRef);
const COLS = 12;
const ROWS = 2;
const cx = (COLS - 1) / 2;
const pixels: ReactNode[] = [];
let accentPlaced = false;
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
// Az sayıda, iri piksel: ortada yoğun, kenara doğru seyrelen küçük küme
const dx = Math.abs(x - cx) / cx;
const rowFalloff = y === 0 ? 0.9 : 0.35;
const density = (1 - dx) ** 1.1 * rowFalloff;
if (hash(x, y, seed) > density) continue;
const accent = !accentPlaced && hash(x, y, seed + 99) > 0.8;
if (accent) accentPlaced = true;
pixels.push(
pixelRect(x, y, {
key: `${x}-${y}`,
accent,
}),
);
}
}
return (
<svg
ref={svgRef}
viewBox={`0 0 ${COLS * CELL} ${ROWS * CELL}`}
aria-hidden="true"
focusable="false"
className={cn("h-auto w-full text-primary", className)}
>
{pixels}
</svg>
);
}
/** Küçük sabit süslerde (mark/korner) data-px öznitelikli rect. */
function fixedPixel(x: number, y: number, opacity: number) {
return (
<rect
x={x + INSET}
y={y + INSET}
width={DOT}
height={DOT}
rx={2}
data-px=""
data-cx={x + CELL / 2}
data-cy={y + CELL / 2}
data-op={opacity}
fill="currentColor"
fillOpacity={opacity}
/>
);
}
/** Eyebrow rozetlerinde kullanılan 2×2'lik minik piksel imzası. */
export function PixelMark({ className }: { className?: string }) {
const svgRef = useRef<SVGSVGElement>(null);
usePixelHeat(svgRef, { radius: 24 });
return (
<svg
ref={svgRef}
viewBox="0 0 20 20"
aria-hidden="true"
focusable="false"
className={cn("size-3.5 shrink-0", className)}
>
{fixedPixel(0, 0, 0.35)}
<rect x={11.3} y={1.3} width={7.4} height={7.4} rx={2} className="fill-orange-500" fillOpacity={0.85} />
{fixedPixel(0, 10, 0.6)}
{fixedPixel(10, 10, 0.18)}
</svg>
);
}
/** Kart köşelerine iliştirilen sessiz piksel kümesi. */
export function PixelCorner({ className }: { className?: string }) {
const svgRef = useRef<SVGSVGElement>(null);
usePixelHeat(svgRef, { radius: 28 });
return (
<svg
ref={svgRef}
viewBox="0 0 30 30"
aria-hidden="true"
focusable="false"
className={cn("size-7 text-primary", className)}
>
{fixedPixel(20, 0, 0.5)}
{fixedPixel(10, 0, 0.14)}
{fixedPixel(20, 10, 0.26)}
</svg>
);
}
/** Bölüm başlıklarının üstünde duran piksel imzalı eyebrow rozeti. */
export function SectionEyebrow({ children }: { children: ReactNode }) {
return (
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3.5 py-1.5 text-xs font-semibold uppercase tracking-[0.12em] text-primary">
<PixelMark />
{children}
</span>
);
}

View File

@@ -0,0 +1,74 @@
"use client";
import { type ReactNode } from "react";
import { ReactLenis } from "lenis/react";
import {
motion,
useReducedMotion,
useScroll,
useSpring,
useTransform,
useVelocity,
} from "motion/react";
/**
* Vitrin sayfalarının akışkan scroll efekti: Lenis ile lerp'li (yavaşlatılmış,
* momentumlu) kaydırma + scroll hızına bağlı içerik bükülmesi (skew).
* Hızlı kaydırdıkça sayfa hafifçe yana yatar, durunca yayla yerine oturur.
*
* Sohbet ve liste gibi iç scroll alanı olan uygulama sayfalarında KULLANMA —
* Lenis tekerlek olayını sayfaya yönlendirdiği için iç scroll'u bozar,
* transform da `fixed` konumlu çocukları koparır.
*
* Hareket azaltılmışsa hem yumuşatma hem bükülme tamamen kapalıdır.
*/
export function ScrollFlow({
children,
className,
maxSkew = 3,
}: {
children: ReactNode;
className?: string;
/** Derece cinsinden en fazla bükülme açısı. */
maxSkew?: number;
}) {
const reduceMotion = useReducedMotion();
const { scrollY } = useScroll();
const velocity = useVelocity(scrollY);
// Ham hız titrek olur; yay ile yumuşatınca bükülme salınıp yerine oturur
const smoothVelocity = useSpring(velocity, {
damping: 50,
stiffness: 420,
mass: 0.6,
});
// Ölü bölge: yavaş/ufak kaydırmalarda bükülme tamamen 0 kalır — transform
// hiç değişmediği için compositor'a kare başı iş çıkmaz. Eşik üstünde
// kareyle (ease-in) yükselir, 0,01°'lik adımlara yuvarlanır ki yayın
// mikro-salınımları her karede katmanı kirletmesin.
const skewY = useTransform(smoothVelocity, (v) => {
const DEADZONE = 500; // px/sn — bunun altı "normal gezinme" sayılır
const FULL = 2600; // px/sn — bu hızda maxSkew'e ulaşılır
const mag = Math.abs(v);
if (mag < DEADZONE) return 0;
const t = Math.min(1, (mag - DEADZONE) / (FULL - DEADZONE));
const s = t * t * maxSkew * Math.sign(v);
return Math.round(s * 100) / 100;
});
if (reduceMotion) {
return <div className={className}>{children}</div>;
}
return (
<ReactLenis root options={{ lerp: 0.09, anchors: true }}>
{/* willChange: bükülme 0'a döndüğünde dev katman GPU'dan düşüp
bir sonraki tekerlekte yeniden rasterize edilmesin */}
<motion.div
className={className}
style={{ skewY, willChange: "transform" }}
>
{children}
</motion.div>
</ReactLenis>
);
}

View File

@@ -14,12 +14,14 @@ export function SiteHeader() {
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between gap-3 px-4">
<Link
href="/"
className="group flex items-center gap-3 font-heading text-xl font-bold"
className="group flex items-center gap-3 font-bricolage text-xl"
>
<span className="flex size-11 items-center justify-center rounded-full bg-primary text-white transition-transform duration-200 group-hover:-rotate-6">
<GraduationCap className="size-6" aria-hidden />
<span>
<span className="font-light text-3xl tracking-tighter">Kolay</span>
<span className="font-semibold text-3xl tracking-tighter">
Tercih
</span>
</span>
KolayTercih
</Link>
<nav className="hidden items-center gap-1 rounded-full border border-slate-200 bg-white p-1.5 text-base font-medium text-slate-600 sm:flex">
{navLinks.map((link) => (

View File

@@ -0,0 +1,166 @@
"use client";
/**
* Dot-matrix Türkiye silüeti — hero arka planı için dekoratif SVG.
* Izgara, gerçek sınır poligonlarından 64×28 hücreye rasterize edildi;
* Marmara Denizi boşluğu bilinçli olarak açık.
*
* Performans kurgusu: ~1000 piksel, React elemanı olarak DEĞİL, modül
* yüklenirken tek sefer üretilen statik bir SVG string'i olarak basılır
* (dangerouslySetInnerHTML). Böylece her sayfa yenilemesinde React bu
* elemanları yeniden kurmaz/hydrate etmez; sunucudan gelen HTML olduğu
* gibi kullanılır. Deterministik üretim sayesinde server ve client aynı
* çıktıyı verir (hydration uyumsuzluğu olmaz).
*
* Cursor etkileşimi: imleç yaklaşınca pikseller anında turuncuya ısınır,
* imleç uzaklaşınca yavaşça kendi mavi tonuna soğur (asimetrik zamanlama).
* Fırça mantığı usePixelHeat hook'unda ortaklaştırıldı; DOM'a
* querySelectorAll ile bağlandığı için statik markup'la sorunsuz çalışır.
*/
import { useRef } from "react";
import { usePixelHeat } from "@/components/use-pixel-heat";
const GRID = [
"..######..............##########................................",
"..#######...........##############..............................",
"..#######.........#################................#####........",
".########...###########################...........########......",
".######.....###############################################.....",
".###........###############################################.....",
".##.......##################################################....",
".###########################################################....",
".#############################################################..",
"..#############################################################.",
"...############################################################.",
"...##########################################################...",
"...##########################################################...",
"...###########################################################..",
"..############################################################..",
"..############################################################..",
"...###########################################################..",
"....##########################################################..",
"....##########################################################..",
".....#########################################################..",
".....###################################################..##....",
".....############################################...............",
"......##########..######################...###..................",
"...........#####....#########....#####..........................",
"............###.......#####......####...........................",
".................................###............................",
];
// Vurgu pikselleri: öğrenci yoğunluğu yüksek şehirler (col, row)
const CITIES = [
{ name: "İstanbul", x: 8, y: 4 },
{ name: "Ankara", x: 23, y: 9 },
{ name: "İzmir", x: 4, y: 16 },
{ name: "Antalya", x: 16, y: 22 },
{ name: "Kayseri", x: 32, y: 14 },
{ name: "Trabzon", x: 46, y: 5 },
{ name: "Diyarbakır", x: 47, y: 18 },
{ name: "Van", x: 58, y: 15 },
];
const CELL = 10;
const DOT = 7.4;
const INSET = (CELL - DOT) / 2;
const COLS = GRID[0].length;
const ROWS = GRID.length;
// Güney kıyının altına doğru seyrelerek dağılan serpinti derinliği (hücre)
const SCATTER_DEPTH = 9;
const VIEW_W = COLS * CELL;
const VIEW_H = (ROWS + SCATTER_DEPTH) * CELL;
// Deterministik doku: hücreye göre sabit opaklık (hydration güvenli)
const OPACITIES = [0.3, 0.16, 0.24, 0.12, 0.27, 0.19];
const opacityFor = (x: number, y: number) =>
OPACITIES[(x * 7 + y * 11) % OPACITIES.length];
const cityCells = new Set(CITIES.map((c) => `${c.x},${c.y}`));
// Deterministik sözde-rastgele (pixel-decor ile aynı): server ve client
// aynı serpinti desenini üretir
const hash = (x: number, y: number, seed = 0) => {
const n = Math.sin(x * 127.1 + y * 311.7 + seed * 74.7) * 43758.5453;
return n - Math.floor(n);
};
/* ------------------------------------------------------------------ */
/* Statik markup: modül yüklenirken TEK SEFER üretilir */
/* ------------------------------------------------------------------ */
const rectAttrs = (x: number, y: number) =>
`x="${x * CELL + INSET}" y="${y * CELL + INSET}" width="${DOT}" height="${DOT}" rx="2"`;
const MAP_MARKUP = (() => {
const parts: string[] = [];
GRID.forEach((row, y) => {
for (let x = 0; x < row.length; x++) {
if (row[x] !== "#" || cityCells.has(`${x},${y}`)) continue;
const op = opacityFor(x, y);
parts.push(
`<rect data-px data-cx="${x * CELL + CELL / 2}" data-cy="${y * CELL + CELL / 2}" data-op="${op}" ${rectAttrs(x, y)} fill="currentColor" fill-opacity="${op}"/>`,
);
}
});
// Silüetin altına serpinti: her kolonun en alttaki kara hücresinden
// aşağı doğru, kıyı hattını izleyerek seyrelen pikseller. Hover
// fırçasına da katılırlar (data-px).
const lastLand: number[] = Array(COLS).fill(-1);
GRID.forEach((row, y) => {
for (let x = 0; x < row.length; x++) {
if (row[x] === "#") lastLand[x] = y;
}
});
let scatterAccent = 0;
for (let x = 0; x < COLS; x++) {
if (lastLand[x] < 0) continue;
for (let d = 1; d <= SCATTER_DEPTH; d++) {
const y = lastLand[x] + d;
if (y >= ROWS + SCATTER_DEPTH) break;
// Kıyıdan uzaklaştıkça seyrelen doluluk; küçük taban sayesinde
// en derin sıralarda da tek tük piksel kalır
const density = 0.05 + 0.4 * (1 - (d - 1) / SCATTER_DEPTH) ** 2;
if (hash(x, y) > density) continue;
if (hash(x, y, 99) > 0.96) {
// Nadir turuncu vurgu: şehir pikselleriyle aynı nabız dili
parts.push(
`<rect ${rectAttrs(x, y)} class="animate-pulse fill-orange-500/70 motion-reduce:animate-none" style="animation-duration:3.6s;animation-delay:${(scatterAccent++ % 8) * 0.45}s"/>`,
);
continue;
}
// Derinlikle sönen opaklık
const op =
Math.round(opacityFor(x, y) * (1 - d / (SCATTER_DEPTH + 1)) * 100) /
100;
parts.push(
`<rect data-px data-cx="${x * CELL + CELL / 2}" data-cy="${y * CELL + CELL / 2}" data-op="${op}" ${rectAttrs(x, y)} fill="currentColor" fill-opacity="${op}"/>`,
);
}
}
// Şehir pikselleri: turuncu, hafif nabız; hover fırçasına katılmazlar
CITIES.forEach((city, i) => {
parts.push(
`<rect ${rectAttrs(city.x, city.y)} class="animate-pulse fill-orange-500/70 motion-reduce:animate-none" style="animation-duration:3.6s;animation-delay:${i * 0.45}s"/>`,
);
});
return parts.join("");
})();
export function TurkeyPixelMap({ className }: { className?: string }) {
const svgRef = useRef<SVGSVGElement>(null);
usePixelHeat(svgRef);
return (
<svg
ref={svgRef}
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
className={className}
aria-hidden="true"
focusable="false"
// Statik, deterministik markup — her render'da yeniden kurulmaz
dangerouslySetInnerHTML={{ __html: MAP_MARKUP }}
/>
);
}

View File

@@ -5,8 +5,6 @@ import React from "react"
export interface LoaderProps {
variant?:
| "circular"
| "classic"
| "pulse"
| "pulse-dot"
| "dots"
@@ -22,78 +20,6 @@ export interface LoaderProps {
className?: string
}
export function CircularLoader({
className,
size = "md",
}: {
className?: string
size?: "sm" | "md" | "lg"
}) {
const sizeClasses = {
sm: "size-4",
md: "size-5",
lg: "size-6",
}
return (
<div
className={cn(
"border-primary animate-spin rounded-full border-2 border-t-transparent",
sizeClasses[size],
className
)}
>
<span className="sr-only">Loading</span>
</div>
)
}
export function ClassicLoader({
className,
size = "md",
}: {
className?: string
size?: "sm" | "md" | "lg"
}) {
const sizeClasses = {
sm: "size-4",
md: "size-5",
lg: "size-6",
}
const barSizes = {
sm: { height: "6px", width: "1.5px" },
md: { height: "8px", width: "2px" },
lg: { height: "10px", width: "2.5px" },
}
return (
<div className={cn("relative", sizeClasses[size], className)}>
<div className="absolute h-full w-full">
{[...Array(12)].map((_, i) => (
<div
key={i}
className="bg-primary absolute animate-[spinner-fade_1.2s_linear_infinite] rounded-full"
style={{
top: "0",
left: "50%",
marginLeft:
size === "sm" ? "-0.75px" : size === "lg" ? "-1.25px" : "-1px",
transformOrigin: `${size === "sm" ? "0.75px" : size === "lg" ? "1.25px" : "1px"} ${size === "sm" ? "10px" : size === "lg" ? "14px" : "12px"}`,
transform: `rotate(${i * 30}deg)`,
opacity: 0,
animationDelay: `${i * 0.1}s`,
height: barSizes[size].height,
width: barSizes[size].width,
}}
/>
))}
</div>
<span className="sr-only">Loading</span>
</div>
)
}
export function PulseLoader({
className,
size = "md",
@@ -461,16 +387,12 @@ export function TextDotsLoader({
}
function Loader({
variant = "circular",
variant = "dots",
size = "md",
text,
className,
}: LoaderProps) {
switch (variant) {
case "circular":
return <CircularLoader size={size} className={className} />
case "classic":
return <ClassicLoader size={size} className={className} />
case "pulse":
return <PulseLoader size={size} className={className} />
case "pulse-dot":
@@ -492,7 +414,7 @@ function Loader({
case "loading-dots":
return <TextDotsLoader text={text} size={size} className={className} />
default:
return <CircularLoader size={size} className={className} />
return <DotsLoader size={size} className={className} />
}
}

View File

@@ -2,7 +2,7 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
@@ -25,7 +25,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
<span className="block size-2.5 animate-pulse rounded-full bg-current" />
),
}}
style={

View File

@@ -0,0 +1,109 @@
"use client";
/**
* Hero'daki (TurkeyPixelMap) imleç ısı fırçasının ortak hook hali.
* `data-px` işaretli rect'ler imleç yaklaşınca anında turuncuya ısınır,
* uzaklaşınca yavaşça kendi rengine soğur (asimetrik zamanlama).
*
* SVG katmanları çoğunlukla pointer-events-none olduğundan imleç window
* üzerinden izlenir ve viewBox koordinatına çevrilir. Beklenen veri
* öznitelikleri: data-px, data-cx, data-cy, data-op (taban opaklık).
*/
import { useEffect, type RefObject } from "react";
import { animate, type AnimationPlaybackControls } from "motion";
const FINE_POINTER_QUERY = "(hover: hover) and (pointer: fine)";
const HOT_COLOR = "#f97316"; // orange-500
const HOT_OPACITY = 0.85;
const HEAT_DURATION = 0.15; // ısınma: anlık geri bildirim
const COOL_DURATION = 1.4; // soğuma: iz gibi yavaşça söner
type HeatTarget = {
el: SVGRectElement;
cx: number;
cy: number;
baseOpacity: number;
};
export function usePixelHeat(
svgRef: RefObject<SVGSVGElement | null>,
{
/** Fırça yarıçapı, SVG (viewBox) biriminde */
radius = 34,
}: { radius?: number } = {},
) {
useEffect(() => {
const svg = svgRef.current;
if (!svg) return;
// Dokunmatik cihazlarda hover yanlış tetiklenir; efekti imleçli cihazlara sakla
if (!window.matchMedia(FINE_POINTER_QUERY).matches) return;
const viewW = svg.viewBox.baseVal.width;
if (!viewW) return;
const baseColor = getComputedStyle(svg).color;
const pixels: HeatTarget[] = Array.from(
svg.querySelectorAll<SVGRectElement>("[data-px]"),
).map((el) => ({
el,
cx: Number(el.dataset.cx),
cy: Number(el.dataset.cy),
baseOpacity: Number(el.dataset.op),
}));
if (pixels.length === 0) return;
const hot = new Set<SVGRectElement>();
const controls = new Map<SVGRectElement, AnimationPlaybackControls>();
const run = (
el: SVGRectElement,
to: { fill: string; fillOpacity: number },
duration: number,
) => {
controls.get(el)?.stop();
controls.set(el, animate(el, to, { duration, ease: "easeOut" }));
};
const onMove = (e: PointerEvent) => {
const rect = svg.getBoundingClientRect();
if (rect.width === 0) return;
const scale = rect.width / viewW;
const pad = radius * scale;
// İmleç fırça menzilinin tamamen dışındaysa ve sıcak piksel yoksa
// hiç hesap yapma (sayfada birden çok örnek dinlediği için önemli)
if (
hot.size === 0 &&
(e.clientX < rect.left - pad ||
e.clientX > rect.right + pad ||
e.clientY < rect.top - pad ||
e.clientY > rect.bottom + pad)
) {
return;
}
const x = (e.clientX - rect.left) / scale;
const y = (e.clientY - rect.top) / scale;
for (const p of pixels) {
const d2 = (p.cx - x) ** 2 + (p.cy - y) ** 2;
const isHot = d2 <= radius * radius;
if (isHot && !hot.has(p.el)) {
hot.add(p.el);
run(p.el, { fill: HOT_COLOR, fillOpacity: HOT_OPACITY }, HEAT_DURATION);
} else if (!isHot && hot.has(p.el)) {
hot.delete(p.el);
run(
p.el,
{ fill: baseColor, fillOpacity: p.baseOpacity },
COOL_DURATION,
);
}
}
};
window.addEventListener("pointermove", onMove, { passive: true });
return () => {
window.removeEventListener("pointermove", onMove);
controls.forEach((c) => c.stop());
};
}, [svgRef, radius]);
}