This commit is contained in:
2025-04-08 23:22:18 +05:00
commit 43e711d393
39 changed files with 1793 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
import useModalStore from "../stores/useModalStore";
import DisclaimerIcon from "./icons/map/DisclaimerIcon";
import DisclaimerModal from "./modals/DisclaimerModal";
import Button from "./ui/Button";
export default function BottomPanel() {
const { setModal } = useModalStore();
return (
<div className="absolute bottom-0 left-0 w-full z-20 max-[1440px]:hidden flex justify-between items-end gap-2 touch-none p-6">
<Button
size="small"
variant="secondary"
className="!bg-black/40 !rounded-full px-2 py-1 lg:px-[0.556vw] lg:py-[0.278vw] hover:!bg-[#0D1922]/40 cursor-pointer"
// onClick={() => setModal(<DisclaimerModal />)}
>
<span className="min-w-4 min-h-4 w-[1.111vw] h-[1.111vw] text-white">
<DisclaimerIcon />
</span>
<span className="text-white text-xs lg:text-[0.833vw] font-semibold">
Disclaimer
</span>
</Button>
</div>
);
}
+539
View File
@@ -0,0 +1,539 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { AnimatePresence, motion } from "motion/react";
import clsx from "clsx";
import { useRef, useState, useEffect } from "react";
import Marker from "./Marker";
import IMarker from "../types/IMarker";
import { markers } from "../data/markers";
import SearchIcon from "./icons/map/SearchIcon";
import MoveIcon from "./icons/map/MoveIcon";
import WeatherWidget from "./WeatherWidget";
import BottomPanel from "./BottomPanel";
interface Position {
x: number;
y: number;
}
interface Size {
width: number;
height: number;
}
interface MapProps {
maxZoom?: number;
}
const constrainPosition = (
position: Position,
containerSize: Size,
imageSize: Size,
zoom: number
): Position => {
const scaledWidth = imageSize.width * zoom;
const scaledHeight = imageSize.height * zoom;
const minX = containerSize.width - scaledWidth;
const minY = containerSize.height - scaledHeight;
return {
x: Math.min(0, Math.max(minX, position.x)),
y: Math.min(0, Math.max(minY, position.y)),
};
};
const getEventPosition = (
e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent
): Position => {
if ("touches" in e) {
return {
x: e.touches[0].clientX,
y: e.touches[0].clientY,
};
}
return {
x: e.clientX,
y: e.clientY,
};
};
const calculateMinZoom = (containerSize: Size, imageSize: Size): number => {
if (imageSize.width === 0 || imageSize.height === 0) {
return 0.1;
}
const widthRatio = containerSize.width / imageSize.width;
const heightRatio = containerSize.height / imageSize.height;
return Math.max(widthRatio, heightRatio);
};
function Map({ maxZoom = 0.8 }: MapProps) {
const [isDragging, setIsDragging] = useState(false);
const [startPosition, setStartPosition] = useState<Position>({ x: 0, y: 0 });
const [position, setPosition] = useState<Position>({ x: 0, y: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<HTMLImageElement>(null);
const [zoom, setZoom] = useState(0);
const [originalSize, setOriginalSize] = useState<Size>({
width: 0,
height: 0,
});
const previousTouchDistance = useRef<number | null>(null);
const initialTouchDistance = useRef<number | null>(null);
const containerSizeRef = useRef<Size>({ width: 0, height: 0 });
const minZoomRef = useRef<number>(1);
const markersContainerRef = useRef<HTMLDivElement>(null);
const [hoveredMarker, setHoveredMarker] = useState<IMarker | null>(null);
const [isImageLoaded, setIsImageLoaded] = useState(false);
const animationRef = useRef<number | null>(null);
const [lastClickTime, setLastClickTime] = useState(0);
const [isShowInstruction, setIsShowInstruction] = useState(true);
useEffect(() => {
if (!containerRef.current || !isImageLoaded || originalSize.width === 0)
return;
const containerRect = containerRef.current.getBoundingClientRect();
const scaledWidth = originalSize.width * zoom;
const scaledHeight = originalSize.height * zoom;
const maxOffsetX = Math.max(0, scaledWidth - containerRect.width);
const maxOffsetY = Math.max(0, scaledHeight - containerRect.height);
const desiredOffsetX = containerRect.width * 0.46;
const desiredOffsetY = containerRect.height * 0.5;
const boundedOffsetX = Math.min(desiredOffsetX, maxOffsetX);
const boundedOffsetY = Math.min(desiredOffsetY, maxOffsetY);
setPosition({
x: -boundedOffsetX,
y: -boundedOffsetY,
});
}, [originalSize, isImageLoaded]);
function handleLoad() {
if (!mapRef.current || !containerRef.current) return;
// Создаем временное изображение для гарантированного получения размеров
const img = new Image();
img.src = mapRef.current.src;
img.onload = () => {
const newOriginalSize = {
width: img.naturalWidth || img.width,
height: img.naturalHeight || img.height,
};
setOriginalSize(newOriginalSize);
// Рассчитываем минимальный зум после получения размеров
const containerRect = containerRef.current!.getBoundingClientRect();
const minZoom = calculateMinZoom(
{
width: containerRect.width,
height: containerRect.height,
},
newOriginalSize
);
minZoomRef.current = minZoom;
setZoom(minZoom);
setIsImageLoaded(true);
};
}
// Update container size and min zoom on resize
useEffect(() => {
const updateContainerSize = () => {
if (!containerRef.current) return;
const containerRect = containerRef.current.getBoundingClientRect();
const newContainerSize = {
width: containerRect.width,
height: containerRect.height,
};
containerSizeRef.current = newContainerSize;
// Recalculate min zoom when container size changes
const newMinZoom = calculateMinZoom(newContainerSize, originalSize);
minZoomRef.current = newMinZoom;
// Adjust zoom if it's below new minimum
if (zoom < newMinZoom) {
setZoom(newMinZoom);
updatePosition(position, newMinZoom);
}
};
updateContainerSize();
window.addEventListener("resize", updateContainerSize);
return () => {
window.removeEventListener("resize", updateContainerSize);
};
}, [originalSize, zoom]);
const getContainerSize = (): Size => {
return containerSizeRef.current;
};
const updatePosition = (newPosition: Position, newZoom: number = zoom) => {
if (!containerRef.current) return;
const containerSize = getContainerSize();
const constrainedPosition = constrainPosition(
newPosition,
containerSize,
originalSize,
newZoom
);
setPosition(constrainedPosition);
};
const zoomToPoint = (point: Position, targetZoom: number) => {
if (!containerRef.current) return;
// Ensure zoom is within bounds
const boundedZoom = Math.min(
maxZoom,
Math.max(minZoomRef.current, targetZoom)
);
const containerRect = containerRef.current.getBoundingClientRect();
const mouseX = point.x - containerRect.left;
const mouseY = point.y - containerRect.top;
const scale = boundedZoom / zoom;
const dx = mouseX - position.x;
const dy = mouseY - position.y;
const newPosition = {
x: mouseX - dx * scale,
y: mouseY - dy * scale,
};
setZoom(boundedZoom);
updatePosition(newPosition, boundedZoom);
};
const animateZoom = (
point: Position,
startZoom: number,
endZoom: number,
startTime: number,
duration: number = 300
) => {
const currentTime = Date.now();
const elapsed = currentTime - startTime;
if (elapsed >= duration) {
zoomToPoint(point, endZoom);
animationRef.current = null;
return;
}
// Используем easeOutCubic для плавной анимации
const progress = 1 - Math.pow(1 - elapsed / duration, 3);
const currentZoom = startZoom + (endZoom - startZoom) * progress;
zoomToPoint(point, currentZoom);
animationRef.current = requestAnimationFrame(() =>
animateZoom(point, startZoom, endZoom, startTime, duration)
);
};
useEffect(() => {
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, []);
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
if (isShowInstruction) {
setIsShowInstruction(false);
}
if (e.touches.length === 2) {
// Для щипка сразу сохраняем начальную дистанцию
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const distance = Math.hypot(
touch1.clientX - touch2.clientX,
touch1.clientY - touch2.clientY
);
initialTouchDistance.current = distance;
previousTouchDistance.current = distance;
return;
}
if (e.touches.length === 1) {
handleStart(e);
}
};
const handleTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
if (!containerRef.current) return;
if (e.touches.length === 2) {
e.preventDefault(); // Предотвращаем скролл страницы при щипке
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const distance = Math.hypot(
touch1.clientX - touch2.clientX,
touch1.clientY - touch2.clientY
);
if (initialTouchDistance.current === null) {
initialTouchDistance.current = distance;
previousTouchDistance.current = distance;
return;
}
if (previousTouchDistance.current === null) {
previousTouchDistance.current = distance;
return;
}
// Увеличиваем порог изменения для более плавного зума
const distanceChange = Math.abs(distance - previousTouchDistance.current);
const changePercentage =
(distanceChange / previousTouchDistance.current) * 100;
if (changePercentage >= 5) {
// Уменьшаем порог с 10 до 5 для более отзывчивого зума
const zoomFactor = distance > previousTouchDistance.current ? 1.1 : 0.9;
const centerX = (touch1.clientX + touch2.clientX) / 2;
const centerY = (touch1.clientY + touch2.clientY) / 2;
const newZoom = Math.min(
maxZoom,
Math.max(minZoomRef.current, zoom * zoomFactor)
);
// Проверяем, действительно ли изменился зум
if (Math.abs(newZoom - zoom) > 0.001) {
setZoom(newZoom);
const containerRect = containerRef.current.getBoundingClientRect();
const mouseX = centerX - containerRect.left;
const mouseY = centerY - containerRect.top;
const scale = newZoom / zoom;
const dx = mouseX - position.x;
const dy = mouseY - position.y;
const newPosition = {
x: mouseX - dx * scale,
y: mouseY - dy * scale,
};
updatePosition(newPosition, newZoom);
}
previousTouchDistance.current = distance;
}
return;
}
// Обработка перетаскивания одним пальцем только если не в режиме щипка
if (isDragging && e.touches.length === 1) {
const { x, y } = getEventPosition(e);
const newPosition = {
x: x - startPosition.x,
y: y - startPosition.y,
};
updatePosition(newPosition);
}
};
const handleEnd = () => {
setIsDragging(false);
};
const handleTouchEnd = () => {
setIsDragging(false);
previousTouchDistance.current = null;
initialTouchDistance.current = null;
};
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
setIsShowInstruction(false);
if (!containerRef.current || !mapRef.current) return;
const containerRect = containerRef.current.getBoundingClientRect();
const mouseX = e.clientX - containerRect.left;
const mouseY = e.clientY - containerRect.top;
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.min(
maxZoom,
Math.max(minZoomRef.current, zoom * zoomFactor)
);
if (Math.abs(newZoom - zoom) < 0.01) return;
const scale = newZoom / zoom;
const dx = mouseX - position.x;
const dy = mouseY - position.y;
const newPosition = {
x: mouseX - dx * scale,
y: mouseY - dy * scale,
};
if (isDragging) {
const eventPosition = getEventPosition(e);
setStartPosition({
x: eventPosition.x - newPosition.x,
y: eventPosition.y - newPosition.y,
});
}
setZoom(newZoom);
updatePosition(newPosition, newZoom);
};
const handleMouseMove = (
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
) => {
if (!isDragging || !containerRef.current) return;
const { x, y } = getEventPosition(e);
const newPosition = {
x: x - startPosition.x,
y: y - startPosition.y,
};
updatePosition(newPosition);
};
const handleStart = (
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
) => {
if (!mapRef.current) return;
if (isShowInstruction) {
setIsShowInstruction(false);
}
setIsDragging(true);
const { x, y } = getEventPosition(e);
setStartPosition({
x: x - position.x,
y: y - position.y,
});
};
const handleClick = (
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
) => {
const currentTime = Date.now();
if (currentTime - lastClickTime < 200) {
if (animationRef.current) cancelAnimationFrame(animationRef.current);
const targetZoom =
Math.abs(zoom - maxZoom) < 0.01 ? minZoomRef.current : maxZoom;
const point = getEventPosition(e);
animationRef.current = requestAnimationFrame(() =>
animateZoom(point, zoom, targetZoom, Date.now())
);
setLastClickTime(0);
} else {
setLastClickTime(currentTime);
}
};
useEffect(() => {
document.addEventListener("wheel", handleWheel, { passive: false });
return () => {
document.removeEventListener("wheel", handleWheel);
};
}, [isDragging, position]);
// Разделяем стили трансформации для изображения и контейнера маркеров
const imageStyle = {
translate: `${position.x}px ${position.y}px`,
scale: zoom,
...originalSize,
transformOrigin: "0 0",
};
return (
<div
ref={containerRef}
className="overflow-hidden h-screen w-screen relative select-none touch-none"
style={{ cursor: isDragging ? "grabbing" : "grab" }}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
onTouchMove={handleTouchMove}
onMouseDown={handleStart}
onMouseUp={handleEnd}
onMouseLeave={handleEnd}
onMouseMove={handleMouseMove}
onClick={handleClick}
>
<img
ref={mapRef}
src="/images/map.jpg"
alt="map"
className={clsx(
"pointer-events-none absolute max-w-none will-change-[transform,scale,opacity,filter]",
"transition-[opacity,filter] duration-300",
isImageLoaded && originalSize.width !== 0
? "opacity-100"
: "opacity-0",
hoveredMarker && "brightness-[80%]"
)}
style={imageStyle}
onLoad={handleLoad}
/>
<div ref={markersContainerRef} className="absolute" style={imageStyle}>
<div className="relative h-full">
{markers.map((marker) => (
<Marker
key={marker.id}
marker={marker}
zoom={zoom}
onHover={setHoveredMarker}
hoveredMarker={hoveredMarker}
/>
))}
</div>
</div>
<WeatherWidget />
<AnimatePresence>
{isShowInstruction && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-30 flex items-center justify-center pointer-events-none"
>
<div className="w-fit bg-[#0D1922]/40 rounded-lg backdrop-blur-sm space-y-3 p-4 text-white">
<div className="flex items-center justify-center gap-4">
<SearchIcon />
<div className="h-4 w-px bg-white"></div>
<MoveIcon />
</div>
<div className="">
<p className="text-sm">Zoom and Move to select a location</p>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<BottomPanel />
</div>
);
}
export default Map;
+1
View File
@@ -0,0 +1 @@
+69
View File
@@ -0,0 +1,69 @@
import { useRef } from "react";
import IMarker from "../types/IMarker";
import clsx from "clsx";
interface MarkerProps {
marker: IMarker;
zoom: number;
onHover: (marker: IMarker | null) => void;
hoveredMarker: IMarker | null;
}
function Marker({ marker, zoom, onHover, hoveredMarker }: MarkerProps) {
const ref = useRef<HTMLImageElement>(null);
const popupRef = useRef<HTMLImageElement>(null);
return (
<div
ref={ref}
key={marker.id}
className={clsx(
"absolute -translate-x-1/2 -translate-y-1/2 cursor-pointer will-change-[transform,filter,scale] transition-[transform,filter,scale] duration-300",
hoveredMarker
? hoveredMarker.id === marker.id
? "z-10 scale-110"
: "brightness-[80%]"
: ""
)}
style={{
top: marker.y,
left: marker.x,
transform: `scale(${0.8 / zoom})`,
}}
onMouseEnter={() => {
onHover(marker);
}}
onMouseLeave={() => {
onHover(null);
}}
onClick={() => {
console.log(marker.name);
}}
>
<img
ref={ref}
src={marker.src}
alt={marker.name}
className="pointer-events-none"
/>
<div
className={clsx(
"absolute bottom-[10%]",
marker.popupPosition === "left" ? "right-full" : "left-full"
)}
>
<img
ref={popupRef}
src={`/images/markers/popups/${marker.name}.png`}
alt={marker.name}
className="pointer-events-none"
style={{
minWidth: `${popupRef.current?.naturalWidth}px`,
}}
/>
</div>
</div>
);
}
export default Marker;
+92
View File
@@ -0,0 +1,92 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useEffect, useRef } from "react";
import useModalStore from "../stores/useModalStore";
import { AnimatePresence, motion } from "motion/react";
import CloseIcon from "./icons/CloseIcon";
import Button from "./ui/Button";
import { clsx as cn } from "clsx";
function ModalContainer() {
const { modal, setModal } = useModalStore();
const divRef = useRef<HTMLDivElement>(null);
const backdropRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
function handleResize() {
if (!popoverRef.current) return;
if (divRef.current!.clientHeight > popoverRef.current!.clientHeight) {
backdropRef.current!.style.height = `${divRef.current!.clientHeight}px`;
} else {
backdropRef.current!.style.height = `100%`;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key !== "Escape") return;
setModal(null);
}
useEffect(() => {
window.addEventListener("resize", handleResize);
window.addEventListener("keydown", handleKeydown);
return () => {
window.removeEventListener("resize", handleResize);
window.removeEventListener("keydown", handleKeydown);
};
}, []);
return (
<AnimatePresence>
{modal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="h-full"
>
<div
ref={popoverRef}
className={cn(
"fixed inset-0 bg-black/70 overflow-y-auto flex flex-col justify-center items-center",
// position === "center" && "items-center",
// position === "right" && "items-end"
)}
>
<div className="max-h-full">
<div ref={divRef} className="p-[0.972vw]">
<div
ref={backdropRef}
className="absolute inset-0 cursor-pointer"
onClick={() => setModal(null)}
/>
<div
ref={containerRef}
className="relative w-full"
// style={{
// height: `calc(${backdropRef.current?.clientHeight}px - 0.972vw * 2)`,
// }}
>
{modal}
<Button
onlyIcon
className="absolute top-[1.667vw] right-[1.667vw] p-[0.556vw] !rounded-full bg-[#F9F9F9]"
onClick={() => setModal(null)}
>
<span className="w-[1.389vw] h-[1.389vw] text-black">
<CloseIcon />
</span>
</Button>
</div>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
export default ModalContainer;
+45
View File
@@ -0,0 +1,45 @@
import { useEffect, useState } from "react";
import { getWeather } from "../api/weather";
export default function WeatherWidget() {
const [temperature, setTemperature] = useState(0);
const date = new Date();
const day = date.toLocaleDateString("en-US", {
weekday: "short",
});
const month = date.toLocaleDateString("en-US", {
month: "short",
});
const hours = date.getHours() > 12 ? date.getHours() - 12 : date.getHours();
const minutes = String(date.getMinutes()).padStart(2, "0");
const dayPart = `${date.getHours() >= 12 ? "PM" : "AM"}`;
const formattedTime = `${hours}:${minutes}`;
useEffect(() => {
getWeather().then((data) => {
setTemperature(Math.round(data));
});
}, []);
return (
<div className="z-20 fixed left-10 top-10 rounded-2xl space-y-4 min-w-50 w-[7.5vw] p-4 font-medium text-white bg-black/40 pointer-events-none max-[1440px]:hidden">
<div>
<div className="flex justify-between">
<p>{day}</p>
<p>{formattedTime}</p>
</div>
<div className="flex justify-between opacity-60">
<p>
{date.getDate()} {month}
</p>
<p>{dayPart}</p>
</div>
</div>
<hr className="border-white -mx-4" />
<p className="text-[32px]">{temperature}°C</p>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
export default function CloseIcon() {
return (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="m10 10 4.714-4.714M10 10 5.286 5.286M10 10l4.714 4.714M10 10l-4.714 4.714"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,16 @@
export default function DisclaimerIcon() {
return (
<svg
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.167 8A6.167 6.167 0 1 1 1.833 8a6.167 6.167 0 0 1 12.334 0Z"
stroke="currentColor"
/>
<path d="M8 4.667v4" stroke="currentColor" strokeLinecap="round" />
<circle cx="8" cy="11.333" fill="currentColor" r=".667" />
</svg>
);
}
+19
View File
@@ -0,0 +1,19 @@
function MoveIcon() {
return (
<svg
width={49}
height={48}
viewBox="0 0 49 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m11.9 29-5.4-5m0 0 5.4-5m-5.4 5h11.7m12.6 0h11.7m0 0-5.4 5m5.4-5-5.4-5m-7.6 17.6-5 5.4m0 0-5-5.4m5 5.4V30.3m0-12.6V6m0 0 5 5.4m-5-5.4-5 5.4"
stroke="#fff"
strokeWidth={3}
/>
</svg>
);
}
export default MoveIcon;
+21
View File
@@ -0,0 +1,21 @@
function SearchIcon() {
return (
<svg
width={49}
height={48}
viewBox="0 0 49 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m41.5 41-7.686-7.686m0 0A15.95 15.95 0 0 0 38.5 22c0-8.837-7.163-16-16-16s-16 7.163-16 16 7.163 16 16 16a15.95 15.95 0 0 0 11.306-4.678zM16.5 22h12m-6-6v12"
stroke="#fff"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default SearchIcon;
+35
View File
@@ -0,0 +1,35 @@
export default function DisclaimerModal() {
return (
<div className="bg-white z-40 rounded-lg lg:col-span-4 col-span-8 col-start-3 lg:col-start-5 py-[37px] px-8 w-1/3 self-center">
<h2 className="text-subheadline-m font-semibold py-6">Disclaimer</h2>
<div className="flex flex-col gap-4">
<p className="text-caption-m">
This masterplan has been designed solely to provide an impression of
the Rove Home projects as well as the approximate location of
existing and proposed facilities, services, and destinations and is
not intended for any other purpose.
</p>
<p className="text-caption-m">
All elements including the interior design used in the units and
images shown in the virtual tour are only for illustration. The
pictures of the proposed residential units, furniture, landscaping,
amenities, color schemes, fixtures, and accessories among all other
items are illustrative to showcase the units.
</p>
<p className="text-caption-m">
IRTH does not make any representation or give any warranty
concerning the future developments shown, or the current or future
amenities, location, or existence of any facilities, services, and
destinations. Any indications of distance, sizes, travel times, and
any other information are approximate and for indicative purposes
only and are not to scale.
</p>
<p className="text-caption-m">
IRTH gives notice that this virtual tour (including units,
amenities, plans of the property) does not constitute any part of a
sale offer or sale and purchase contract.
</p>
</div>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import React from 'react';
import { clsx } from 'clsx';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactNode;
variant?: 'link' | 'primary' | 'secondary' | 'tertiary';
className?: string;
size?: 'small' | 'medium' | 'large';
onlyIcon?: boolean;
ref?: React.RefObject<HTMLButtonElement | null>;
}
function Button({
children,
variant = 'primary',
size = 'medium',
onlyIcon,
className,
ref,
...props
}: ButtonProps) {
return (
<button
ref={ref}
{...props}
className={clsx(
'transition-all rounded-lg flex items-center justify-center',
variant !== 'link' && [
size === 'small' && (onlyIcon ? 'p-2' : 'px-3 py-2 gap-2'),
size === 'medium' && (onlyIcon ? 'p-3.5' : 'px-5 py-3.5 gap-3.5'),
size === 'large' && (onlyIcon ? 'p-4' : 'px-6 py-4 gap-4'),
],
variant === 'link' && 'text-sm text-black/50 w-fit',
variant === 'primary' && 'bg-[#1E1E1E] text-white',
variant === 'secondary' && 'bg-white',
variant === 'tertiary' &&
'bg-transparent text-[#767676] hover:bg-black/5',
className
)}
>
{children}
</button>
);
}
export default Button;