feat: Add draggable window component and associated functionality

- Implemented DraggableWindose component with drag-and-drop capabilities.
- Added shake detection to close the window after multiple shakes.
- Integrated responsive design for mobile devices.
- Created TXT component to display a clickable text icon with hover effects.
- Included image assets for the draggable window and text icon.
This commit is contained in:
암냥 2025-12-20 01:13:28 +09:00
commit 82b5b0719c
No known key found for this signature in database
6 changed files with 182 additions and 2 deletions

BIN
public/tlqkf.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

BIN
public/txt.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

View file

@ -1,5 +1,6 @@
@import url("https://cdn.jsdelivr.net/gh/wanteddev/wanted-sans@v1.0.3/packages/wanted-sans/fonts/webfonts/variable/split/WantedSansVariable.min.css");
@import url('https://fonts.googleapis.com/css2?family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&display=swap');
@import url('https://cdn.jsdelivr.net/npm/galmuri@latest/dist/galmuri.css');
@import "tailwindcss";
@import "tw-animate-css";
@ -14,6 +15,10 @@
font-family: "NType82Headline", sans-serif !important;
}
.font-galmuri {
font-family: "Galmuri", sans-serif !important;
}
:root {
--background: hsl(340 40% 98%);
--foreground: hsl(315 21% 8%);

View file

@ -10,13 +10,24 @@ import { Banner } from "@/components/ui/banner";
import { LinkIcon, TreeDeciduous, TreePalmIcon, TreesIcon } from "lucide-react";
import { useState } from "react";
import Contact from "@/components/Contact";
import DraggableWindose from "@/components/DraggableWindose";
import TXT from "@/components/txt";
export default function Page() {
const { theme } = useTheme();
const [show, setShow] = useState(true);
const [targetPosition, setTargetPosition] = useState<{ x: number; y: number } | null>(null);
const [windowVisible, setWindowVisible] = useState(true);
const handleTXTHover = (position: { x: number; y: number } | null) => {
setTargetPosition(position);
if (position) {
setWindowVisible(true);
}
};
return (
<main className="min-h-screen w-screen overflow-y-scroll snap-y snap-mandatory">
<main className="min-h-screen w-screen overflow-y-scroll snap-y snap-mandatory relative">
<Snowfall color={theme === 'dark' ? '#ffffff' : '#bcbcd6ff'} />
<div className="max-w-3xl w-full flex flex-row h-auto mx-auto gap-4 items-center my-8 lg:px-0 px-8">
@ -54,7 +65,8 @@ export default function Page() {
/>
</div>
<DraggableWindow />
<DraggableWindose targetPosition={targetPosition} isVisible={windowVisible} onClose={() => setWindowVisible(false)} onDragStart={() => setTargetPosition(null)} />
<TXT onHover={handleTXTHover} />
<div className="max-w-3xl w-full">
<p> <strong></strong> <strong></strong> <strong>, </strong> .</p>
<p><strong> </strong> , .</p>

View file

@ -0,0 +1,141 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import Image from 'next/image';
import { useIsMobile } from '@/hooks/use-mobile';
export default function DraggableWindose({ targetPosition, isVisible, onClose, onDragStart }: { targetPosition: { x: number; y: number } | null; isVisible: boolean; onClose: () => void; onDragStart?: () => void }) {
const isMobile = useIsMobile();
const [position, setPosition] = useState({ x: 100, y: 100 });
const [isDragging, setIsDragging] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [shakeCount, setShakeCount] = useState(0);
const windowRef = useRef<HTMLDivElement>(null);
const lastPositionRef = useRef({ x: 100, y: 100 });
const shakeThresholdRef = useRef(0);
const handleMouseDown = (e: React.MouseEvent) => {
if (e.button !== 0) return; // Only left click
setIsDragging(true);
setShakeCount(0);
shakeThresholdRef.current = 0;
onDragStart?.();
if (windowRef.current) {
const rect = windowRef.current.getBoundingClientRect();
setDragOffset({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
}
};
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
const newX = e.clientX - dragOffset.x;
const newY = e.clientY - dragOffset.y;
setPosition({
x: newX,
y: newY,
});
// 흔드는 감지
const distance = Math.sqrt(
Math.pow(newX - lastPositionRef.current.x, 2) +
Math.pow(newY - lastPositionRef.current.y, 2)
);
lastPositionRef.current = { x: newX, y: newY };
shakeThresholdRef.current += distance;
if (shakeThresholdRef.current > 2000) {
setShakeCount(prev => prev + 1);
shakeThresholdRef.current = 0;
if (shakeCount >= 2) {
onClose();
setIsDragging(false);
}
}
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, dragOffset, shakeCount]);
useEffect(() => {
if (isDragging || targetPosition) return;
const interval = setInterval(() => {
setPosition({
x: Math.random() * (window.innerWidth - 500),
y: Math.random() * (window.innerHeight - 400),
});
}, 500);
return () => clearInterval(interval);
}, [isDragging, targetPosition]);
useEffect(() => {
if (targetPosition && !isDragging) {
setPosition(targetPosition);
}
}, [targetPosition, isDragging]);
return (
isMobile ? (
<figure className="mb-8 w-full h-auto">
<picture className="block bg-gray-100 rounded-xl aspect-3-2 overflow-hidden image-scale object-shadowed">
<Image
src="/full.webp"
alt="Banner"
width={1200}
height={400}
priority
className="object-cover object-center transition-transform duration-300 hover:scale-105"
/>
</picture>
</figure >
) : (
isVisible && (
<div
ref={windowRef}
className="fixed cursor-move select-none z-50"
style={{
left: `${position.x}px`,
top: `${position.y}px`,
transition: isDragging ? 'none' : targetPosition ? 'left 0.1s, top 0.1s' : 'left 0.4s ease-in-out, top 0.4s ease-in-out',
}}
onMouseDown={handleMouseDown}
>
<div className="relative w-fit h-fit">
<Image
src="/tlqkf.webp"
alt="Draggable Window"
width={500}
height={400}
priority
draggable={false}
/>
<button
onClick={() => onClose()}
className="absolute top-1 right-2 w-5 h-5 cursor-pointer"
aria-label="Close window"
/>
</div>
</div>
)
)
);
}

22
src/components/txt.tsx Normal file
View file

@ -0,0 +1,22 @@
import Image from "next/image";
interface TXTProps {
onHover?: (position: { x: number; y: number } | null) => void;
}
export default function TXT({ onHover }: TXTProps) {
return (
<div
className="flex flex-col items-center justify-center gap-4 absolute top-4 left-4 p-2 cursor-pointer font-galmuri drag-none select-none"
onMouseEnter={() => onHover?.({ x: 16, y: 16 })}
onMouseLeave={() => onHover?.(null)}
onClick={() => {
alert("왜 편법을 쓰지?");
window.location.reload();
}}
>
<Image src="/txt.webp" alt="txt" width={24} height={24} />
<span className="text-sm font-medium text-foreground/80 font-galmuri">.txt</span>
</div>
);
}