parent
108cf60e0c
commit
58ce75b38a
85 changed files with 966 additions and 17255 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB |
|
|
@ -1,194 +0,0 @@
|
|||
@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";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
|
||||
@font-face {
|
||||
font-family: "NType82Headline";
|
||||
src: url("https://f.imnya.ng/font/NType82-Headline.woff2") format("woff2");
|
||||
}
|
||||
|
||||
.font-ntype {
|
||||
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%);
|
||||
--card: hsl(340 40% 98%);
|
||||
--card-foreground: hsl(315 21% 8%);
|
||||
--popover: hsl(340 40% 98%);
|
||||
--popover-foreground: hsl(315 21% 8%);
|
||||
--primary: hsl(340 25% 15%);
|
||||
--primary-foreground: hsl(0 0% 98%);
|
||||
--secondary: hsl(340 25% 95%);
|
||||
--secondary-foreground: hsl(240 5.9% 10%);
|
||||
--muted: hsl(340 20% 95%);
|
||||
--muted-foreground: hsl(340 10% 60%);
|
||||
--accent: hsl(340 25% 94%);
|
||||
--accent-foreground: hsl(240 5.9% 10%);
|
||||
--destructive: hsl(0 84.2% 60.2%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(340 25% 90%);
|
||||
--input: hsl(340 25% 90%);
|
||||
--ring: hsl(315 21% 8%);
|
||||
--chart-1: hsl(12 76% 61%);
|
||||
--chart-2: hsl(173 58% 39%);
|
||||
--chart-3: hsl(197 37% 24%);
|
||||
--chart-4: hsl(43 74% 66%);
|
||||
--chart-5: hsl(27 87% 67%);
|
||||
--radius: 0.625rem;
|
||||
--sidebar-background: hsl(340 25% 98%);
|
||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||
--sidebar-primary: hsl(240 5.9% 10%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||
--sidebar-accent: hsl(340 20% 95%);
|
||||
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
||||
--sidebar-border: hsl(340 20% 90%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
|
||||
--scrollbar: hsla(340 10% 60% / 0.5);
|
||||
--scrollbar-hover: hsla(340 10% 60% / 0.8);
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: hsl(315 21% 8%);
|
||||
--foreground: hsl(0 0% 98%);
|
||||
--card: hsl(315 21% 8%);
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
--popover: hsl(315 21% 8%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--primary: hsl(0 0% 98%);
|
||||
--primary-foreground: hsl(240 5.9% 10%);
|
||||
--secondary: hsl(296, 18%, 15%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(296, 18%, 15%);
|
||||
--muted-foreground: hsl(240 5% 68%);
|
||||
--accent: hsl(296, 18%, 15%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(296, 18%, 15%);
|
||||
--input: hsl(296, 18%, 15%);
|
||||
--ring: hsl(240 4.9% 83.9%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar-background: hsl(240 5.9% 10%);
|
||||
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-primary: hsl(224.3 76.3% 48%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(296, 18%, 15%);
|
||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-border: hsl(296, 18%, 15%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
--sidebar: hsl(240 5.9% 10%);
|
||||
|
||||
--scrollbar: hsla(340 10% 60% / 0.5);
|
||||
--scrollbar-hover: hsla(340 10% 60% / 0.8);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: "Wanted Sans Variable", "Wanted Sans", -apple-system,
|
||||
BlinkMacSystemFont, system-ui, "Segoe UI", "Apple SD Gothic Neo",
|
||||
"Noto Sans KR", "Malgun Gothic", "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol", sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
.image-scale {
|
||||
transition-property:scale,border-radius box-shadow;
|
||||
transition-duration:.7s;
|
||||
transition-timing-function:var(--ease-out-expo)
|
||||
}
|
||||
@media (hover:hover) {
|
||||
.image-scale:hover {
|
||||
--tw-scale-x:105%;
|
||||
--tw-scale-y:105%;
|
||||
--tw-scale-z:105%;
|
||||
scale:var(--tw-scale-x)var(--tw-scale-y);
|
||||
border-radius:var(--radius-lg)
|
||||
}
|
||||
}
|
||||
.image-scale:hover {
|
||||
z-index:10;
|
||||
box-shadow:0 15px 45px #0006
|
||||
}
|
||||
|
||||
/* Fullscreen Scroll Snap */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
scroll-snap-type: y mandatory;
|
||||
}
|
||||
|
||||
.snap-section {
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import type { Metadata } from "next";
|
||||
import { ThemeProvider } from "@/components/theme-provider"
|
||||
|
||||
import "./globals.css";
|
||||
import "./scrollbar.css";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Geist } from "next/font/google";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "암냥 | :two_hearts: imnya.ng",
|
||||
description: "imnyang's Portfolio Site",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning className={cn("font-sans", geist.variable)}>
|
||||
<head />
|
||||
<body className="antialiased">
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<TooltipProvider>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<figure>
|
||||
<picture className="block bg-gray-100 rounded-xl aspect-1-1 overflow-hidden image-scale object-shadowed mb-8">
|
||||
<img src="https://api.imnya.ng/mitda/miku" width={200} height={200} alt="Not Found Image" />
|
||||
</picture>
|
||||
</figure>
|
||||
<h1 className="text-4xl font-bold">404</h1>
|
||||
<p>괜찮아요. 이런날도 있는거죠</p>
|
||||
<br/>
|
||||
<a href="/">← Go Home</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
|
||||
import Projects from "@/components/Projects";
|
||||
import TimelineComponent from "@/components/timeline";
|
||||
import Image from "next/image";
|
||||
import ReadmeWindow from "@/components/ReadmeWindow";
|
||||
import Contact from "@/components/Contact";
|
||||
import DDayComponent from "@/components/dday";
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
<main className="min-h-screen w-screen overflow-y-scroll snap-y snap-mandatory relative">
|
||||
<div className="max-w-3xl w-full flex flex-row h-auto mx-auto my-8 items-center justify-between lg:px-0 px-8">
|
||||
<div className="flex flex-row gap-4 items-center">
|
||||
<Image
|
||||
src="/Frame.svg"
|
||||
alt="logo"
|
||||
className="w-fit h-fit"
|
||||
width={30}
|
||||
height={30}
|
||||
/>
|
||||
<div>
|
||||
<h1 className="font-ntype text-3xl text-foreground/70">
|
||||
<a href="mailto:me@imnya.ng">me@imnya.ng</a>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<DDayComponent targetDate={new Date('2010-11-08T03:00:00+09:00')} label="🎂 Birth" />
|
||||
</div>
|
||||
<section id="about" className="w-full snap-start snap-always flex flex-col items-center justify-center mx-auto px-8 lg:px-0">
|
||||
<div className="max-w-3xl w-full">
|
||||
<Contact />
|
||||
<br />
|
||||
<p>하고 싶은 꿈을 현실로 만들어보는 암냥입니다.</p>
|
||||
<p>언제나 새로운 것을 배우고, 도전하고 있습니다.</p>
|
||||
<p>현재는 정보보안 분야쪽에 관심이 많으며 원래는 소프트웨어 개발에 관심 많았습니다.</p>
|
||||
<br/>
|
||||
<p id="a" className="select-none text-transparent text-[1pt]"></p>
|
||||
<Projects />
|
||||
<br />
|
||||
<TimelineComponent />
|
||||
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<br />
|
||||
<p>© 2026 imnyanle.g. All rights reserved.</p>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
122
src/components/Contact.astro
Normal file
122
src/components/Contact.astro
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
---
|
||||
const contacts = [
|
||||
{ name: "Email", icon: "mail", action: "dialog" },
|
||||
{ name: "Blog", icon: "rss", url: "https://blog.imnya.ng" },
|
||||
{ name: "GitHub", icon: "github", url: "https://github.com/imnyang" },
|
||||
{ name: "git.mizuki.guru", icon: "git", url: "https://git.mizuki.guru/imnyang" },
|
||||
{ name: "Instagram", icon: "instagram", url: "https://instagram.com/imnya.ng" },
|
||||
{ name: "𝕏", icon: "x", url: "https://x.com/imnya_ng" },
|
||||
];
|
||||
|
||||
const emailContacts = [
|
||||
{ label: "Personal", address: "me@imnya.ng" },
|
||||
{ label: "ADOFAI.gg", address: "imnyang@adofai.gg" },
|
||||
{ label: "Dazzle.st", address: "imnyang@dazzle.st" },
|
||||
];
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
mail: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>`,
|
||||
rss: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/></svg>`,
|
||||
github: `<img src="/icon/github.svg" width="24" height="24" alt="GitHub" class="w-6 h-6" />`,
|
||||
git: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>`,
|
||||
instagram: `<img src="/icon/instagram.svg" width="18" height="18" alt="Instagram" class="w-4 h-4" />`,
|
||||
x: `<p class="text-[20px] font-bold leading-none">𝕏</p>`,
|
||||
};
|
||||
---
|
||||
|
||||
<div class="flex flex-row space-x-4 relative">
|
||||
{contacts.map((c) => (
|
||||
c.action === "dialog" ? (
|
||||
<button
|
||||
type="button"
|
||||
id="email-btn"
|
||||
title={c.name}
|
||||
class="flex items-center space-x-2 text-foreground/80 hover:text-foreground transition-colors hover:cursor-pointer"
|
||||
set:html={icons[c.icon]}
|
||||
/>
|
||||
) : (
|
||||
<a
|
||||
href={c.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={c.name}
|
||||
class="flex items-center space-x-2 text-foreground/80 hover:text-foreground transition-colors"
|
||||
set:html={icons[c.icon]}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- Email Dialog -->
|
||||
<div
|
||||
id="email-dialog"
|
||||
class="hidden fixed inset-0 z-50 flex items-center justify-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Contact Me"
|
||||
>
|
||||
<div id="email-dialog-backdrop" class="absolute inset-0 bg-black/50"></div>
|
||||
<div class="relative z-10 bg-background border border-border rounded-xl shadow-xl p-6 w-full max-w-sm mx-4">
|
||||
<h2 class="text-lg font-semibold mb-1">Contact Me</h2>
|
||||
<p class="text-sm text-muted-foreground mb-4">이메일 주소를 클릭하면 복사되고, 더블클릭하면 메일 앱이 열립니다.</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
{emailContacts.map((ec) => (
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground mb-1">{ec.label}</p>
|
||||
<button
|
||||
type="button"
|
||||
data-email={ec.address}
|
||||
class="email-copy-btn w-full flex items-center gap-3 border border-border rounded-lg px-4 py-2.5 hover:bg-muted transition-colors text-left active:scale-95 transition-transform"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
|
||||
<span class="flex-1 text-sm">{ec.address}</span>
|
||||
<span class="copy-icon text-muted-foreground">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
id="email-dialog-close"
|
||||
class="px-4 py-2 rounded bg-muted text-foreground text-sm hover:bg-muted/80 transition-colors cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const dialog = document.getElementById("email-dialog")!;
|
||||
const openBtn = document.getElementById("email-btn")!;
|
||||
const closeBtn = document.getElementById("email-dialog-close")!;
|
||||
const backdrop = document.getElementById("email-dialog-backdrop")!;
|
||||
|
||||
const open = () => dialog.classList.remove("hidden");
|
||||
const close = () => dialog.classList.add("hidden");
|
||||
|
||||
openBtn.addEventListener("click", open);
|
||||
closeBtn.addEventListener("click", close);
|
||||
backdrop.addEventListener("click", close);
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>(".email-copy-btn").forEach((btn) => {
|
||||
const email = btn.dataset.email!;
|
||||
const copyIcon = btn.querySelector(".copy-icon")!;
|
||||
|
||||
btn.addEventListener("click", async () => {
|
||||
await navigator.clipboard.writeText(email);
|
||||
copyIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>`;
|
||||
setTimeout(() => {
|
||||
copyIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
|
||||
}, 1200);
|
||||
});
|
||||
|
||||
btn.addEventListener("dblclick", () => {
|
||||
window.location.href = `mailto:${email}`;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
"use client";
|
||||
import { MailIcon, Rss, GitBranch, Copy, Check } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import Image from "next/image";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogDescription, DialogFooter, DialogClose, DialogTitle } from "./ui/dialog";
|
||||
import React from "react";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item"
|
||||
|
||||
type ContactMethod = {
|
||||
name: string;
|
||||
icon: React.ReactNode;
|
||||
url?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type EmailContact = {
|
||||
label: string;
|
||||
address: string;
|
||||
};
|
||||
|
||||
|
||||
export default function Contact() {
|
||||
const [isDialogOpen, setIsDialogOpen] = React.useState(false);
|
||||
const [copiedEmail, setCopiedEmail] = React.useState<string | null>(null);
|
||||
const emailContacts: EmailContact[] = [
|
||||
{ label: "Personal", address: "me@imnya.ng" },
|
||||
{ label: "ADOFAI.gg", address: "imnyang@adofai.gg" },
|
||||
{ label: "Dazzle.st", address: "imnyang@dazzle.st" },
|
||||
];
|
||||
|
||||
const contact: ContactMethod[] = [
|
||||
{
|
||||
"name": "Email",
|
||||
"onClick": () => setIsDialogOpen(true),
|
||||
"icon": <MailIcon className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
"name": "Blog",
|
||||
"url": "https://blog.imnya.ng",
|
||||
"icon": <Rss className="w-5 h-5" />
|
||||
},
|
||||
{
|
||||
"name": "GitHub",
|
||||
"url": "https://github.com/imnyang",
|
||||
"icon": <Image src="/icon/github.svg" width={24} height={24} alt="GitHub" className="w-6 h-6" />
|
||||
},
|
||||
{
|
||||
"name": "git.mizuki.guru",
|
||||
"url": "https://git.mizuki.guru/imnyang",
|
||||
"icon": <GitBranch className="w-5 h-5" />
|
||||
},
|
||||
{
|
||||
"name": "Instagram",
|
||||
"url": "https://instagram.com/imnya.ng",
|
||||
"icon": <Image src="/icon/instagram.svg" width={18} height={18} alt="Instagram" className="w-4 h-4" />
|
||||
},
|
||||
{
|
||||
"name": "𝕏",
|
||||
"url": "https://x.com/imnya_ng",
|
||||
"icon": <p className="text-[20px] font-bold">𝕏</p>
|
||||
}
|
||||
]
|
||||
|
||||
const handleClick = (method: ContactMethod) => {
|
||||
if (method.onClick) {
|
||||
method.onClick();
|
||||
} else if (method.url) {
|
||||
window.open(method.url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
};
|
||||
|
||||
const copyEmail = (email: string) => {
|
||||
void navigator.clipboard.writeText(email);
|
||||
setCopiedEmail(email);
|
||||
|
||||
setTimeout(() => {
|
||||
setCopiedEmail((currentEmail) => (currentEmail === email ? null : currentEmail));
|
||||
}, 1200);
|
||||
};
|
||||
|
||||
const openMailTo = (email: string) => {
|
||||
window.location.href = `mailto:${email}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-row space-x-4">
|
||||
{contact.map((method) => (
|
||||
<Tooltip key={method.name} delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleClick(method)}
|
||||
className="flex items-center space-x-2 text-foreground/80 hover:text-foreground transition-colors hover:cursor-pointer"
|
||||
>
|
||||
{method.icon}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{method.name}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Contact Me</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="flex flex-col gap-4">
|
||||
{emailContacts.map((emailContact) => (
|
||||
<div key={emailContact.address}>
|
||||
<p>{emailContact.label} : </p>
|
||||
<Item variant="outline" size="sm" asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyEmail(emailContact.address)}
|
||||
onDoubleClick={() => openMailTo(emailContact.address)}
|
||||
className="active:scale-95 transition-transform"
|
||||
>
|
||||
<ItemMedia>
|
||||
<MailIcon className="size-5" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{emailContact.address}</ItemTitle>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
{copiedEmail === emailContact.address ? (
|
||||
<Check className="size-4" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</ItemActions>
|
||||
</button>
|
||||
</Item>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="sm:justify-end">
|
||||
<DialogClose asChild>
|
||||
<Button type="button">Close</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/components/DDay.astro
Normal file
27
src/components/DDay.astro
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
export interface Props {
|
||||
targetDate: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const { targetDate, label } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="flex flex-col items-end text-right text-sm">
|
||||
<span id="dday-label">D-Day</span>
|
||||
<span class="text-muted-foreground">{label} | {new Date(targetDate).toDateString()}</span>
|
||||
</div>
|
||||
|
||||
<script define:vars={{ targetDate }}>
|
||||
const today = new Date();
|
||||
const target = new Date(targetDate);
|
||||
const diffTime = target.getTime() - today.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
let label = "D-Day";
|
||||
if (diffDays > 0) label = `D-${diffDays}`;
|
||||
else if (diffDays < 0) label = `D+${Math.abs(diffDays)}`;
|
||||
|
||||
const el = document.getElementById('dday-label');
|
||||
if (el) el.textContent = label;
|
||||
</script>
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
'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="/windose.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>
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
|
||||
export default function DraggableWindow() {
|
||||
const isMobile = useIsMobile();
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [position, setPosition] = useState({ x: 100, y: 100 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
const [isNadaeVisible, setIsNadaeVisible] = useState(false);
|
||||
const windowRef = useRef<HTMLDivElement>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const nadaeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
setIsDragging(true);
|
||||
if (windowRef.current) {
|
||||
const rect = windowRef.current.getBoundingClientRect();
|
||||
setDragOffset({
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = () => {
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio('/audio.opus');
|
||||
}
|
||||
|
||||
audioRef.current.currentTime = 0;
|
||||
void audioRef.current.play();
|
||||
void fetch('https://api.imnya.ng/nadae', {
|
||||
method: 'PUT',
|
||||
}).catch(() => null);
|
||||
|
||||
setIsNadaeVisible(true);
|
||||
|
||||
if (nadaeTimeoutRef.current) {
|
||||
clearTimeout(nadaeTimeoutRef.current);
|
||||
}
|
||||
|
||||
nadaeTimeoutRef.current = setTimeout(() => {
|
||||
setIsNadaeVisible(false);
|
||||
nadaeTimeoutRef.current = null;
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
setPosition({
|
||||
x: e.clientX - dragOffset.x,
|
||||
y: e.clientY - dragOffset.y,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, dragOffset]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
audioRef.current?.pause();
|
||||
audioRef.current = null;
|
||||
|
||||
if (nadaeTimeoutRef.current) {
|
||||
clearTimeout(nadaeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
isMobile ? (
|
||||
<figure className="mb-8 w-full h-auto">
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full rounded-xl text-left"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</figure >
|
||||
) : (
|
||||
isVisible && (
|
||||
<div
|
||||
ref={windowRef}
|
||||
className="fixed cursor-grab select-none active:cursor-grabbing"
|
||||
style={{
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
}}
|
||||
>
|
||||
<div className="relative w-fit h-fit">
|
||||
<Image
|
||||
src={isNadaeVisible ? '/window-nadae.webp' : '/window.webp'}
|
||||
alt="Draggable Window"
|
||||
width={500}
|
||||
height={400}
|
||||
priority
|
||||
draggable={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={handleActivate}
|
||||
className="absolute inset-0 cursor-grab active:cursor-grab"
|
||||
aria-label="Play audio"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsVisible(false);
|
||||
}}
|
||||
className="absolute top-1 right-2 z-10 w-5 h-5 cursor-pointer"
|
||||
aria-label="Close window"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
90
src/components/Projects.astro
Normal file
90
src/components/Projects.astro
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
---
|
||||
const projects = [
|
||||
{
|
||||
name: 'EPC 2025 Broadcast Manager',
|
||||
url: 'https://www.youtube.com/playlist?list=PLZeYZotn5_IOJDek6e35NKzUtJm09yxZD',
|
||||
desc: 'ADOFAI is web-scale',
|
||||
detail: '달성이 주관하고 ADOFAI.gg가 공동 주최하는 Effect Playing Contest 2025 방송 화면의 대부분의 기능을 개발하였습니다.',
|
||||
tags: ['React', 'ElysiaJS'],
|
||||
},
|
||||
{
|
||||
name: 'newsletter',
|
||||
url: 'https://github.com/imnyang/newsletter',
|
||||
desc: 'For Memos',
|
||||
detail: '그저 이메일이 오면 Discord 웹훅으로 포워딩합니다.',
|
||||
tags: ['Rust', 'IMAP']
|
||||
},
|
||||
{
|
||||
name: 'memos-rss',
|
||||
url: 'https://github.com/imnyang/memos-rss',
|
||||
desc: 'For Memos',
|
||||
detail: 'Discord 포럼 채널에 RSS 피드에 올라온 내용을 포워딩합니다.',
|
||||
tags: ['Rust', 'Discord Bot', 'RSS']
|
||||
},
|
||||
{
|
||||
name: 'today.isangjeong',
|
||||
url: 'https://instagram.com/today.isangjeong',
|
||||
desc: 'Instagram Bot',
|
||||
detail: '매일 학교의 급식 메뉴를 자동으로 업로드하는 인스타그램 봇입니다.',
|
||||
tags: ['TypeScript', 'Instagram', '@napi-rs/canvas']
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<section class="break-keep break-words w-full">
|
||||
<div class="space-y-8" id="projects-list">
|
||||
{projects.map((project, idx) => (
|
||||
<div class={`space-y-2 project-item ${idx >= 3 ? 'hidden' : ''}`}>
|
||||
<div class="space-y-1">
|
||||
<div class="flex justify-between items-center gap-y-2 gap-x-4">
|
||||
{project.url ? (
|
||||
<a href={project.url} target="_blank" rel="noopener noreferrer" class="text-lg text-nowrap items-center gap-x-2 flex flex-row">
|
||||
{project.name}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-arrow-out-up-right"><path d="M21 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6"/><path d="m21 3-9 9"/><path d="M15 3h6v6"/></svg>
|
||||
</a>
|
||||
) : (
|
||||
<span class="text-lg">{project.name}</span>
|
||||
)}
|
||||
<span class="text-sm text-muted-foreground text-nowrap">{project.desc}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">{project.detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 *:bg-muted *:px-2 *:py-0.5 *:rounded *:text-xs *:font-medium *:text-muted-foreground">
|
||||
{project.tags.map((tag) => (
|
||||
<span>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{projects.length > 3 && (
|
||||
<button
|
||||
id="load-more-btn"
|
||||
class="mt-6 bg-muted text-muted-foreground rounded font-medium text-sm hover:bg-muted/80 px-4 py-2 transition-colors cursor-pointer"
|
||||
>
|
||||
더 불러오기
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<script>
|
||||
let visibleCount = 3;
|
||||
const btn = document.getElementById('load-more-btn');
|
||||
const items = document.querySelectorAll('.project-item');
|
||||
|
||||
if (btn) {
|
||||
btn.addEventListener('click', () => {
|
||||
visibleCount += 3;
|
||||
items.forEach((item, idx) => {
|
||||
if (idx < visibleCount) {
|
||||
item.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
if (visibleCount >= items.length) {
|
||||
btn.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
"use client";
|
||||
import { SquareArrowOutUpRight } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
const projects = [
|
||||
{
|
||||
name: 'EPC 2025 Broadcast Manager',
|
||||
url: 'https://www.youtube.com/playlist?list=PLZeYZotn5_IOJDek6e35NKzUtJm09yxZD',
|
||||
desc: 'ADOFAI is web-scale',
|
||||
detail: '달성이 주관하고 ADOFAI.gg가 공동 주최하는 Effect Playing Contest 2025 방송 화면의 대부분의 기능을 개발하였습니다.',
|
||||
tags: ['React', 'ElysiaJS'],
|
||||
},
|
||||
{
|
||||
name: 'newsletter',
|
||||
url: 'https://github.com/imnyang/newsletter',
|
||||
desc: 'For Memos',
|
||||
detail: '그저 이메일이 오면 Discord 웹훅으로 포워딩합니다.',
|
||||
tags: ['Rust', 'IMAP']
|
||||
},
|
||||
{
|
||||
name: 'memos-rss',
|
||||
url: 'https://github.com/imnyang/memos-rss',
|
||||
desc: 'For Memos',
|
||||
detail: 'Discord 포럼 채널에 RSS 피드에 올라온 내용을 포워딩합니다.',
|
||||
tags: ['Rust', 'Discord Bot', 'RSS']
|
||||
},
|
||||
{
|
||||
name: 'today.isangjeong',
|
||||
url: 'https://instagram.com/today.isangjeong',
|
||||
desc: 'Instagram Bot',
|
||||
detail: '매일 학교의 급식 메뉴를 자동으로 업로드하는 인스타그램 봇입니다.',
|
||||
tags: ['TypeScript', 'Instagram', '@napi-rs/canvas']
|
||||
}
|
||||
];
|
||||
|
||||
export default function Projects() {
|
||||
const [visibleCount, setVisibleCount] = React.useState(3);
|
||||
|
||||
return (
|
||||
<section className="break-keep break-words w-full">
|
||||
<div className="space-y-8">
|
||||
{projects.slice(0, visibleCount).map((project, idx) => (
|
||||
<div className="space-y-2" key={idx}>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center gap-y-2 gap-x-4">
|
||||
{project.url ? (
|
||||
<a href={project.url} target="_blank" rel="noopener noreferrer" className="text-lg text-nowrap items-center gap-x-2 flex flex-row">
|
||||
{project.name}
|
||||
<SquareArrowOutUpRight size={16} />
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-lg">{project.name}</span>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground text-nowrap">{project.desc}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{project.detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 *:bg-muted *:px-2 *:py-0.5 *:rounded *:text-xs *:font-medium *:text-muted-foreground">
|
||||
{project.tags.map((tag, i) => (
|
||||
<span key={i}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{visibleCount < projects.length && (
|
||||
<Button
|
||||
onClick={() => setVisibleCount(visibleCount + 3)}
|
||||
className="mt-6 bg-muted text-muted-foreground rounded font-medium text-sm hover:bg-muted/80"
|
||||
>
|
||||
더 불러오기
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useReadmeWaka } from '@/hooks/use-readme-waka';
|
||||
|
||||
export default function ReadmeWindow() {
|
||||
const { wakaContent, isLoading, error } = useReadmeWaka();
|
||||
|
||||
if (!wakaContent) return null;
|
||||
|
||||
return (
|
||||
<div className="font-mono rounded-b-lg">
|
||||
<div className="py-4">
|
||||
<h3 className="text-lg font-bold">WakaTime Stats</h3>
|
||||
<pre className="text-sm overflow-auto max-h-96 whitespace-pre-wrap break-words">
|
||||
{wakaContent.replace(/```(txt)?/g, '')}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
src/components/Timeline.astro
Normal file
135
src/components/Timeline.astro
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
---
|
||||
import { events } from "@/lib/events";
|
||||
|
||||
const years = [
|
||||
...new Set(events.map((e) => new Date(e.date).getFullYear())),
|
||||
].sort((a, b) => b - a);
|
||||
|
||||
const eventsByYear: Record<number, typeof events> = {};
|
||||
for (const year of years) {
|
||||
eventsByYear[year] = events.filter(
|
||||
(e) => new Date(e.date).getFullYear() === year,
|
||||
);
|
||||
}
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const daysUntilYearEnd = Math.ceil(
|
||||
(new Date(currentYear + 1, 0, 1).getTime() - new Date().getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
);
|
||||
---
|
||||
|
||||
<div
|
||||
id="timeline"
|
||||
class="w-full flex flex-col items-center justify-center mt-8"
|
||||
>
|
||||
<div class="w-full">
|
||||
<div class="flex flex-col md:flex-row gap-4 h-full">
|
||||
<!-- Left: Year buttons -->
|
||||
<div
|
||||
class="w-full md:w-24 flex flex-row md:flex-col gap-2 overflow-y-auto pr-2"
|
||||
>
|
||||
{
|
||||
years.map((year, i) => (
|
||||
<button
|
||||
type="button"
|
||||
data-year={year}
|
||||
class:list={[
|
||||
"year-btn font-semibold px-4 py-2 rounded-lg text-sm transition-colors cursor-pointer",
|
||||
i === 0
|
||||
? "bg-foreground text-background"
|
||||
: "border border-border bg-background text-foreground hover:bg-muted",
|
||||
]}
|
||||
>
|
||||
{year}
|
||||
</button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Right: Events -->
|
||||
<div class="flex-1 overflow-y-auto pr-2">
|
||||
{
|
||||
years.map((year, i) => (
|
||||
<div
|
||||
data-events-year={year}
|
||||
class:list={["year-events space-y-2", i !== 0 && "hidden"]}
|
||||
>
|
||||
{eventsByYear[year].map((event) => (
|
||||
<div class="rounded-lg border bg-background px-4 py-3">
|
||||
<div class="flex flex-row gap-2 mb-1">
|
||||
<span class="text-md font-semibold">
|
||||
{new Date(event.date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
<span class="text-md font-semibold text-muted-foreground">
|
||||
ㆍ{event.category}
|
||||
</span>
|
||||
</div>
|
||||
{event.link ? (
|
||||
<a href={event.link} class="">
|
||||
{event.description}{" "}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="inline-block w-4 h-4 mb-1 ml-1"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
</a>
|
||||
) : (
|
||||
<span>{event.description}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{year === currentYear && (
|
||||
<div class="my-8 flex items-center gap-4">
|
||||
<div class="flex-1 h-px bg-gradient-to-r from-transparent via-muted-foreground to-transparent" />
|
||||
<span class="text-xs text-muted-foreground whitespace-nowrap">
|
||||
✨ | D-{daysUntilYearEnd}동안 만들어나갈 멋진 것들을
|
||||
기대해주세요.
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-gradient-to-r from-transparent via-muted-foreground to-transparent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const btns = document.querySelectorAll<HTMLButtonElement>(".year-btn");
|
||||
const panels = document.querySelectorAll<HTMLElement>(".year-events");
|
||||
|
||||
btns.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const year = btn.dataset.year;
|
||||
|
||||
btns.forEach((b) => {
|
||||
b.className =
|
||||
"year-btn font-semibold px-4 py-2 rounded text-sm transition-colors cursor-pointer border border-border bg-background text-foreground hover:bg-muted";
|
||||
});
|
||||
btn.className =
|
||||
"year-btn font-semibold px-4 py-2 rounded text-sm transition-colors cursor-pointer bg-foreground text-background";
|
||||
|
||||
panels.forEach((panel) => {
|
||||
if (panel.dataset.eventsYear === year) {
|
||||
panel.classList.remove("hidden");
|
||||
} else {
|
||||
panel.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
"use client";
|
||||
|
||||
interface DDayComponentProps {
|
||||
targetDate: Date;
|
||||
label: string;
|
||||
|
||||
}
|
||||
|
||||
export default function DDayComponent({ targetDate, label }: DDayComponentProps) {
|
||||
const today = new Date();
|
||||
const diffTime = targetDate.getTime() - today.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
const getLabel = () => {
|
||||
if (diffDays > 0) return `D-${diffDays}`;
|
||||
if (diffDays < 0) return `D+${Math.abs(diffDays)}`;
|
||||
return `D-Day`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end text-right text-sm">
|
||||
<span>{getLabel()}</span>
|
||||
<span className="text-muted-foreground">{label} | {targetDate.toDateString()}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
"use client";
|
||||
import { events } from "@/lib/events";
|
||||
import { LinkIcon } from "lucide-react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function TimelineComponent() {
|
||||
const [selectedYear, setSelectedYear] = useState<number | null>(null);
|
||||
|
||||
const years = Array.from(
|
||||
new Set(events.map((event) => new Date(event.date).getFullYear()))
|
||||
).sort((a, b) => b - a);
|
||||
|
||||
useEffect(() => {
|
||||
if (years.length > 0 && selectedYear === null) {
|
||||
setSelectedYear(years[0]);
|
||||
}
|
||||
}, [years, selectedYear]);
|
||||
|
||||
const filteredEvents = selectedYear
|
||||
? events.filter(
|
||||
(event) => new Date(event.date).getFullYear() === selectedYear
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div
|
||||
id="timeline"
|
||||
className="w-full flex flex-col items-center justify-center mt-8"
|
||||
>
|
||||
<div className="w-full">
|
||||
{/* <h1 className="text-2xl font-bold mb-4 w-full">💫 이 사람은 무엇을 하나요?</h1> */}
|
||||
{/* <br /> */}
|
||||
<div className="flex flex-col md:flex-row gap-4 h-full">
|
||||
{/* Left column - Year buttons */}
|
||||
<div className="w-full md:w-24 flex flex-row md:flex-col gap-2 overflow-y-auto pr-2">
|
||||
{years.map((year) => (
|
||||
<Button
|
||||
key={year}
|
||||
onClick={() => setSelectedYear(year)}
|
||||
variant={selectedYear === year ? "default" : "outline"}
|
||||
className="font-semibold"
|
||||
>
|
||||
{year}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right column - Events */}
|
||||
<div className="flex-1 overflow-y-auto pr-2">
|
||||
<div className="space-y-2">
|
||||
{filteredEvents.map((event, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border bg-background px-4 py-3"
|
||||
>
|
||||
<div className="flex flex-row gap-2 mb-1">
|
||||
<span className="text-md font-semibold fixed-width-number">
|
||||
{new Date(event.date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
<span className="text-md font-semibold fixed-width-number text-muted-foreground">
|
||||
ㆍ{event.category}
|
||||
</span>
|
||||
</div>
|
||||
{event.link ? (
|
||||
<a href={event.link} className="">
|
||||
{event.description}{" "}
|
||||
<LinkIcon className="inline-block w-4 h-4 mb-1 ml-1" />
|
||||
</a>
|
||||
) : (
|
||||
<span>{event.description}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{selectedYear === new Date().getFullYear() && (
|
||||
<div className="my-8 flex items-center gap-4">
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-muted-foreground to-transparent"></div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">✨ | D-{Math.ceil((new Date(new Date().getFullYear() + 1, 0, 1).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))}동안 만들어나갈 멋진 것들을 기대해주세요.</span>
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-muted-foreground to-transparent"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface TXTProps {
|
||||
onHover?: (position: { x: number; y: number } | null) => void;
|
||||
}
|
||||
|
||||
export default function TXT({ onHover }: TXTProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const [openInput, setOpenInput] = useState(false);
|
||||
const [openResult, setOpenResult] = useState(false);
|
||||
const [key, setKey] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError("");
|
||||
setContent("");
|
||||
try {
|
||||
const response = await fetch(`https://api.imnya.ng/secret?key=${encodeURIComponent(key)}`);
|
||||
const data = await response.text();
|
||||
if (data === "false") {
|
||||
setError("아니요?");
|
||||
} else {
|
||||
setOpenInput(false);
|
||||
setOpenResult(true);
|
||||
setContent(data);
|
||||
}
|
||||
} catch (_err) {
|
||||
console.error(_err);
|
||||
setError("오류가 발생했습니다");
|
||||
}
|
||||
};
|
||||
|
||||
if (isMobile) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
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 bg-transparent border-none"
|
||||
onMouseEnter={() => onHover?.({ x: 16, y: 16 })}
|
||||
onMouseLeave={() => onHover?.(null)}
|
||||
onClick={() => setOpenInput(true)}
|
||||
>
|
||||
<Image src="/txt.webp" alt="txt" width={24} height={24} />
|
||||
<span className="text-sm font-medium text-foreground/80 font-galmuri">비밀.txt</span>
|
||||
</button>
|
||||
<Dialog open={openInput} onOpenChange={setOpenInput}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>비밀 코드 입력</DialogTitle>
|
||||
<DialogDescription>
|
||||
비밀 코드를 입력하세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<Input
|
||||
placeholder="비밀 코드"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
/>
|
||||
{error && <p className="text-red-500">{error}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={handleSubmit}>확인</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={openResult} onOpenChange={setOpenResult}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>비밀.txt</DialogTitle>
|
||||
<DialogDescription>
|
||||
{content.split('\n').map((line, idx) => (
|
||||
<span key={idx}>
|
||||
{line}
|
||||
<br />
|
||||
</span>
|
||||
))}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setOpenResult(false)}>닫기</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Accordion as AccordionPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
function Accordion({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return (
|
||||
<AccordionPrimitive.Root
|
||||
data-slot="accordion"
|
||||
className={cn("flex w-full flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("not-last:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
|
||||
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||
size?: "default" | "sm"
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Action
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Cancel
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue