Initial Commit
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { hostKind } from "@/lib/bridge/transport"
|
||||
import type { ReactNode } from "react"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { X } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useLocation } from "react-router-dom"
|
||||
|
||||
/** Tauri 프레임리스 창의 공통 제목줄. 닫기는 트레이로 숨김. */
|
||||
export function DesktopWindowFrame({ children }: { children: ReactNode }) {
|
||||
const { pathname } = useLocation()
|
||||
const palette = pathname === "/snippet" || pathname === "/snap" || pathname.startsWith("/snap/")
|
||||
if (hostKind() !== "tauri") return <>{children}</>
|
||||
|
||||
const run = (command: "window_hide" | "window_drag") => {
|
||||
void invoke(command).catch(() => toast.error("창을 제어하지 못했어. 다시 시도해줘."))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background text-foreground flex h-screen flex-col overflow-hidden">
|
||||
<header
|
||||
className={`flex shrink-0 items-center select-none ${palette ? "h-7" : "border-border h-9 border-b"}`}
|
||||
>
|
||||
<div
|
||||
className="text-muted-foreground flex h-full min-w-0 flex-1 items-center px-4 text-[10px] font-medium"
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === 0) run("window_drag")
|
||||
}}
|
||||
// 창 이동은 포인터 전용 동작이며 버튼과 영역을 분리함.
|
||||
role="presentation"
|
||||
>
|
||||
CodeAssist
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="창 닫기"
|
||||
title="닫기 (트레이로 숨기기)"
|
||||
className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring mr-1 flex h-full w-8 shrink-0 items-center justify-center rounded-md transition-colors focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset"
|
||||
onClick={() => run("window_hide")}
|
||||
>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-auto [&_.h-screen]:h-full">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from "react"
|
||||
|
||||
interface State {
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<{ children: React.ReactNode }, State> {
|
||||
state: State = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
console.error("ErrorBoundary caught:", error, info)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div className="container py-12">
|
||||
<h1 className="mb-2 text-2xl font-semibold">문제가 발생했습니다</h1>
|
||||
<p className="text-muted-foreground mb-4 text-sm">{this.state.error.message}</p>
|
||||
<button className="text-sm underline" onClick={() => this.setState({ error: null })}>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
/** 키캡 뱃지. 단축키 힌트 표시용(예: <Kbd>Ctrl</Kbd><Kbd>N</Kbd>). */
|
||||
export function Kbd({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<kbd className="border-border bg-muted text-muted-foreground inline-flex h-4 min-w-4 items-center justify-center rounded border px-1 font-mono text-[10px] leading-none font-medium">
|
||||
{children}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Link, Outlet } from "react-router-dom"
|
||||
import { PATHS } from "@/config/routes"
|
||||
import { ThemeToggle } from "@/shared/components/ThemeToggle"
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="border-b">
|
||||
<div className="container flex h-14 items-center justify-between">
|
||||
<Link to={PATHS.HOME} className="font-semibold">
|
||||
Frontend Template
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
<main className="container flex min-h-0 flex-1 flex-col py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Outlet, useLocation } from "react-router-dom"
|
||||
import { SessionExpiryDialog } from "@/features/auth/components/SessionExpiryDialog"
|
||||
import { useSlidingRefresh } from "@/features/auth/hooks/useSlidingRefresh"
|
||||
import { PATHS } from "@/config/routes"
|
||||
import { PasteTargetBadge } from "./PasteTargetBadge"
|
||||
|
||||
/** 스니펫·챗봇(snap)을 감싸는 공통 래퍼 — 팔레트 화면들의 공용 UI/기능을 여기 한 곳에 둔다.
|
||||
* 각 화면은 <Outlet/> 으로 들어오고, 공통 요소(붙여넣기 대상 배지 등)는 여기서 한 번만 그린다.
|
||||
* 화면이 생기거나 없어져도 공통 부분은 그대로. (snap 전용 헤더·Ctrl+N 은 안쪽 SnapLayout 몫 — 여긴 진짜 공통만.)
|
||||
* h-screen: 안쪽 SnippetPalettePage 의 h-full 체인이 기댈 명시적 높이. */
|
||||
export function PaletteShell() {
|
||||
const { pathname } = useLocation()
|
||||
useSlidingRefresh()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-screen">
|
||||
<Outlet />
|
||||
{pathname !== PATHS.SNIPPET && <PasteTargetBadge />}
|
||||
</div>
|
||||
<SessionExpiryDialog />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { usePasteTarget } from "@/shared/hooks/usePasteTarget"
|
||||
|
||||
/** 붙여넣기 대상(소환 직전 창) 표시 — 앱 전역 1개만 App 루트에 마운트.
|
||||
* 어느 화면(snap·snippet·앞으로 추가될 것)이든 우상단에 뜸. 각 화면이 따로 안 그림. */
|
||||
export function PasteTargetBadge() {
|
||||
const { name, app } = usePasteTarget()
|
||||
if (!name && !app) return null
|
||||
|
||||
// "앱 · 창제목" 형태. 앱만/제목만 있어도 그것만.
|
||||
const label = [app, name].filter(Boolean).join(" · ")
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border-border bg-popover/90 text-muted-foreground pointer-events-none fixed top-2 right-3 z-50 max-w-[60vw] truncate rounded-full border px-2.5 py-1 font-mono text-[10px] shadow-sm backdrop-blur"
|
||||
title={`붙여넣기 대상: ${label}`}
|
||||
>
|
||||
→ {label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { useAuthStore } from "@/features/auth/store/authStore"
|
||||
import { useMe } from "@/features/auth/hooks/useMe"
|
||||
import ProtectedRoute from "./ProtectedRoute"
|
||||
|
||||
vi.mock("@/features/auth/hooks/useMe", () => ({ useMe: vi.fn() }))
|
||||
|
||||
const user = {
|
||||
id: "u1",
|
||||
email: "user@example.com",
|
||||
userName: null,
|
||||
role: "USER" as const,
|
||||
employeeId: null,
|
||||
department: null,
|
||||
authProvider: "local" as const,
|
||||
}
|
||||
|
||||
function renderRoute() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={["/private"]}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<div>로그인 화면</div>} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/private" element={<div>보호 화면</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
describe("ProtectedRoute 인증 초기화", () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ user })
|
||||
vi.mocked(useMe).mockReset()
|
||||
})
|
||||
it("저장된 user를 서버에서 확인하는 동안 보호 화면을 열지 않음", () => {
|
||||
vi.mocked(useMe).mockReturnValue({ isLoading: true, isError: false } as never)
|
||||
|
||||
renderRoute()
|
||||
|
||||
expect(screen.queryByText("보호 화면")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("저장된 user의 서버 인증이 실패하면 로그인으로 이동", () => {
|
||||
vi.mocked(useMe).mockReturnValue({ isLoading: false, isError: true } as never)
|
||||
|
||||
renderRoute()
|
||||
|
||||
expect(screen.getByText("로그인 화면")).toBeInTheDocument()
|
||||
expect(screen.queryByText("보호 화면")).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect } from "react"
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom"
|
||||
import { useMe } from "@/features/auth/hooks/useMe"
|
||||
import { useAuthStore } from "@/features/auth/store/authStore"
|
||||
import { PATHS } from "@/config/routes"
|
||||
|
||||
/**
|
||||
* 저장된 user를 서버에서 확인한 뒤 보호 화면을 연다.
|
||||
* 인증이 없으면 `/login?from=<원래 경로>` 로 redirect.
|
||||
* `from`은 LoginForm에서 검증 후 사용 (외부 도메인·protocol-relative 차단).
|
||||
*/
|
||||
export default function ProtectedRoute() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const clearUser = useAuthStore((s) => s.clearUser)
|
||||
const me = useMe(user !== null)
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
if (me.isError) clearUser()
|
||||
}, [clearUser, me.isError])
|
||||
|
||||
if (!user || me.isError) {
|
||||
const from = `${location.pathname}${location.search}${location.hash}`
|
||||
const query = from && from !== "/" ? `?from=${encodeURIComponent(from)}` : ""
|
||||
return <Navigate to={`${PATHS.LOGIN}${query}`} replace />
|
||||
}
|
||||
if (me.isLoading) {
|
||||
return <div className="text-muted-foreground p-6">로그인 확인 중...</div>
|
||||
}
|
||||
return <Outlet />
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Monitor, Moon, Sun } from "lucide-react"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu"
|
||||
import { useThemeStore, type Theme } from "@/shared/store/themeStore"
|
||||
|
||||
interface ThemeToggleProps {
|
||||
/** 트리거에 라벨 텍스트 함께 보일지. 기본 false (아이콘만). */
|
||||
showLabel?: boolean
|
||||
}
|
||||
|
||||
const ITEMS: { value: Theme; label: string; icon: typeof Sun }[] = [
|
||||
{ value: "light", label: "라이트", icon: Sun },
|
||||
{ value: "dark", label: "다크", icon: Moon },
|
||||
{ value: "system", label: "시스템", icon: Monitor },
|
||||
]
|
||||
|
||||
export function ThemeToggle({ showLabel = false }: ThemeToggleProps) {
|
||||
const theme = useThemeStore((s) => s.theme)
|
||||
const resolved = useThemeStore((s) => s.resolved)
|
||||
const setTheme = useThemeStore((s) => s.setTheme)
|
||||
|
||||
const TriggerIcon = resolved === "dark" ? Moon : Sun
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={showLabel ? "default" : "icon"}
|
||||
aria-label="모드 변경"
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<TriggerIcon className="h-4 w-4" aria-hidden="true" />
|
||||
{showLabel ? <span className="ml-2 text-sm">모드</span> : null}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-32">
|
||||
{ITEMS.map(({ value, label, icon: Icon }) => (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
onClick={() => setTheme(value)}
|
||||
aria-current={theme === value ? "true" : undefined}
|
||||
className="gap-2"
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
{theme === value ? <span className="ml-auto text-xs">●</span> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { getLastPasteTarget, type PasteTarget } from "@/lib/bridge/bridgeNavigate"
|
||||
|
||||
/** 붙여넣기 대상(소환 직전 창) 스냅샷을 구독 — 어느 화면에서든 쓸 수 있는 공유 훅.
|
||||
* 마운트 시 마지막 값을 읽고, 이후 C#가 보내는 갱신(bridge:pasteTarget)을 반영. */
|
||||
export function usePasteTarget(): PasteTarget {
|
||||
const [target, setTarget] = useState<PasteTarget>(getLastPasteTarget)
|
||||
useEffect(() => {
|
||||
setTarget(getLastPasteTarget())
|
||||
const onTarget = (e: Event) =>
|
||||
setTarget((e as CustomEvent<PasteTarget>).detail ?? { name: "", app: "" })
|
||||
window.addEventListener("bridge:pasteTarget", onTarget)
|
||||
return () => window.removeEventListener("bridge:pasteTarget", onTarget)
|
||||
}, [])
|
||||
return target
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import { useThemeStore } from "./themeStore"
|
||||
|
||||
describe("themeStore", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.documentElement.classList.remove("dark")
|
||||
useThemeStore.setState({ theme: "system" })
|
||||
useThemeStore.getState().applyTheme()
|
||||
})
|
||||
|
||||
it("setTheme('light') 적용 시 html.dark 제거 + resolved=light", () => {
|
||||
useThemeStore.getState().setTheme("light")
|
||||
expect(useThemeStore.getState().theme).toBe("light")
|
||||
expect(useThemeStore.getState().resolved).toBe("light")
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(false)
|
||||
})
|
||||
|
||||
it("setTheme('dark') 적용 시 html.dark 추가 + resolved=dark", () => {
|
||||
useThemeStore.getState().setTheme("dark")
|
||||
expect(useThemeStore.getState().theme).toBe("dark")
|
||||
expect(useThemeStore.getState().resolved).toBe("dark")
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true)
|
||||
})
|
||||
|
||||
it("setTheme('system') + matchMedia=dark 면 resolved=dark", () => {
|
||||
const spy = vi.spyOn(window, "matchMedia").mockImplementation(
|
||||
(q) =>
|
||||
({
|
||||
matches: true,
|
||||
media: q,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList
|
||||
)
|
||||
useThemeStore.getState().setTheme("system")
|
||||
expect(useThemeStore.getState().resolved).toBe("dark")
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it("cycleTheme: light → dark → system → light", () => {
|
||||
useThemeStore.getState().setTheme("light")
|
||||
useThemeStore.getState().cycleTheme()
|
||||
expect(useThemeStore.getState().theme).toBe("dark")
|
||||
useThemeStore.getState().cycleTheme()
|
||||
expect(useThemeStore.getState().theme).toBe("system")
|
||||
useThemeStore.getState().cycleTheme()
|
||||
expect(useThemeStore.getState().theme).toBe("light")
|
||||
})
|
||||
|
||||
it("setTheme 결과가 localStorage에 영속됨 (theme만 저장, resolved 제외)", () => {
|
||||
useThemeStore.getState().setTheme("dark")
|
||||
const raw = localStorage.getItem("theme-store")
|
||||
expect(raw).toBeTruthy()
|
||||
const parsed = JSON.parse(raw!)
|
||||
expect(parsed.state.theme).toBe("dark")
|
||||
expect(parsed.state.resolved).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { create } from "zustand"
|
||||
import { persist, createJSONStorage } from "zustand/middleware"
|
||||
|
||||
export type Theme = "light" | "dark" | "system"
|
||||
export type ResolvedTheme = "light" | "dark"
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme
|
||||
resolved: ResolvedTheme
|
||||
setTheme: (theme: Theme) => void
|
||||
cycleTheme: () => void
|
||||
/** 현재 theme 값으로 resolved를 다시 계산하고 html.dark를 토글. */
|
||||
applyTheme: () => void
|
||||
}
|
||||
|
||||
const MEDIA_QUERY = "(prefers-color-scheme: dark)"
|
||||
|
||||
function systemTheme(): ResolvedTheme {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return "light"
|
||||
return window.matchMedia(MEDIA_QUERY).matches ? "dark" : "light"
|
||||
}
|
||||
|
||||
function resolve(theme: Theme): ResolvedTheme {
|
||||
return theme === "system" ? systemTheme() : theme
|
||||
}
|
||||
|
||||
function applyToDocument(resolved: ResolvedTheme) {
|
||||
if (typeof document === "undefined") return
|
||||
document.documentElement.classList.toggle("dark", resolved === "dark")
|
||||
document.documentElement.style.colorScheme = resolved
|
||||
}
|
||||
|
||||
const ORDER: Theme[] = ["light", "dark", "system"]
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
theme: "light",
|
||||
resolved: resolve("light"),
|
||||
setTheme: (theme) => {
|
||||
const resolved = resolve(theme)
|
||||
applyToDocument(resolved)
|
||||
set({ theme, resolved })
|
||||
},
|
||||
cycleTheme: () => {
|
||||
const idx = ORDER.indexOf(get().theme)
|
||||
const next = ORDER[(idx + 1) % ORDER.length]
|
||||
get().setTheme(next)
|
||||
},
|
||||
applyTheme: () => {
|
||||
const resolved = resolve(get().theme)
|
||||
applyToDocument(resolved)
|
||||
set({ resolved })
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "theme-store",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({ theme: state.theme }),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
// hydration 직후 1회: 복원된 theme로 resolved 재계산 + DOM 반영
|
||||
state?.applyTheme()
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
/** 모듈 import 시점에 matchMedia 변화 구독. system 모드일 때만 resolved 갱신. */
|
||||
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
|
||||
const mq = window.matchMedia(MEDIA_QUERY)
|
||||
const handler = () => {
|
||||
if (useThemeStore.getState().theme === "system") {
|
||||
useThemeStore.getState().applyTheme()
|
||||
}
|
||||
}
|
||||
// addEventListener 미지원 폴백 (구 Safari)
|
||||
if (typeof mq.addEventListener === "function") {
|
||||
mq.addEventListener("change", handler)
|
||||
} else if (
|
||||
typeof (mq as MediaQueryList & { addListener?: typeof handler }).addListener === "function"
|
||||
) {
|
||||
;(mq as MediaQueryList & { addListener: (h: typeof handler) => void }).addListener(handler)
|
||||
}
|
||||
|
||||
// 초기 1회 적용 (persist rehydration 전이라도 OK — applyTheme이 안전하게 처리)
|
||||
useThemeStore.getState().applyTheme()
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// 4단계 swatch → 16개 shadcn 토큰 자동 매핑.
|
||||
// 명도(luminance) 정렬 + WCAG 대비 보정.
|
||||
|
||||
import { PALETTES } from "./palettes"
|
||||
|
||||
// ───────────────────────── 색 유틸 ─────────────────────────
|
||||
|
||||
type Rgb = readonly [number, number, number]
|
||||
|
||||
function hexToRgb(hex: string): Rgb {
|
||||
const h = hex.replace("#", "")
|
||||
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]
|
||||
}
|
||||
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
const c = (n: number) =>
|
||||
Math.max(0, Math.min(255, Math.round(n)))
|
||||
.toString(16)
|
||||
.padStart(2, "0")
|
||||
return "#" + c(r) + c(g) + c(b)
|
||||
}
|
||||
|
||||
// 0~1 범위 luminance (간단형 — WCAG가 아니라 평균 명도)
|
||||
function luminance(hex: string): number {
|
||||
const [r, g, b] = hexToRgb(hex)
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
}
|
||||
|
||||
// 두 색을 t(0~1) 비율로 섞음
|
||||
function mix(a: string, b: string, t: number): string {
|
||||
const [r1, g1, b1] = hexToRgb(a)
|
||||
const [r2, g2, b2] = hexToRgb(b)
|
||||
return rgbToHex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t)
|
||||
}
|
||||
|
||||
// 명도가 minLum 미만이면 흰색 쪽으로 끌어올림
|
||||
function ensureLight(hex: string, minLum: number): string {
|
||||
const lum = luminance(hex)
|
||||
if (lum >= minLum) return hex
|
||||
const t = (minLum - lum) / (1 - lum)
|
||||
return mix(hex, "#FFFFFF", t)
|
||||
}
|
||||
|
||||
// 명도가 maxLum 초과면 검정 쪽으로 끌어내림
|
||||
function ensureDark(hex: string, maxLum: number): string {
|
||||
const lum = luminance(hex)
|
||||
if (lum <= maxLum) return hex
|
||||
const t = (lum - maxLum) / lum
|
||||
return mix(hex, "#000000", t)
|
||||
}
|
||||
|
||||
// ───────────────────────── derive ─────────────────────────
|
||||
|
||||
export type DerivedTokens = {
|
||||
background: string
|
||||
foreground: string
|
||||
card: string
|
||||
cardForeground: string
|
||||
popover: string
|
||||
popoverForeground: string
|
||||
primary: string
|
||||
primaryForeground: string
|
||||
secondary: string
|
||||
secondaryForeground: string
|
||||
muted: string
|
||||
mutedForeground: string
|
||||
accent: string
|
||||
accentForeground: string
|
||||
destructive: string
|
||||
border: string
|
||||
input: string
|
||||
ring: string
|
||||
sidebar: string
|
||||
sidebarForeground: string
|
||||
sidebarPrimary: string
|
||||
sidebarPrimaryForeground: string
|
||||
sidebarAccent: string
|
||||
sidebarAccentForeground: string
|
||||
sidebarBorder: string
|
||||
sidebarRing: string
|
||||
}
|
||||
|
||||
// 의미색 — 모든 테마 공통
|
||||
const DESTRUCTIVE = "oklch(0.577 0.245 27.325)"
|
||||
|
||||
// 사용자가 선택한 목업 색을 그대로 유지. 명도 정렬로 primary가 차콜로 바뀌지 않게 함.
|
||||
const CLEAN_BLUE_TOKENS: DerivedTokens = {
|
||||
background: "#FFFFFF",
|
||||
foreground: "#242C39",
|
||||
card: "#F7F9FC",
|
||||
cardForeground: "#242C39",
|
||||
popover: "#FFFFFF",
|
||||
popoverForeground: "#242C39",
|
||||
primary: "#034EA2",
|
||||
primaryForeground: "#FFFFFF",
|
||||
secondary: "#F0F3F8",
|
||||
secondaryForeground: "#242C39",
|
||||
muted: "#F7F9FC",
|
||||
mutedForeground: "#606D7F",
|
||||
accent: "#EDF3FC",
|
||||
accentForeground: "#034EA2",
|
||||
destructive: DESTRUCTIVE,
|
||||
border: "#E1E6EE",
|
||||
input: "#BBC7D8",
|
||||
ring: "#034EA2",
|
||||
sidebar: "#F7F9FC",
|
||||
sidebarForeground: "#242C39",
|
||||
sidebarPrimary: "#034EA2",
|
||||
sidebarPrimaryForeground: "#FFFFFF",
|
||||
sidebarAccent: "#EDF3FC",
|
||||
sidebarAccentForeground: "#034EA2",
|
||||
sidebarBorder: "#E1E6EE",
|
||||
sidebarRing: "#034EA2",
|
||||
}
|
||||
|
||||
export function derivePaletteTokens(
|
||||
swatch: readonly [string, string, string, string]
|
||||
): DerivedTokens {
|
||||
// 명도 내림차순 정렬: L0(가장 밝음) → L3(가장 어두움)
|
||||
const [L0, L1, L2, L3] = [...swatch].sort((a, b) => luminance(b) - luminance(a))
|
||||
|
||||
// 배경: 가장 밝은 색을 0.92 이상으로 보정
|
||||
const background = ensureLight(L0, 0.92)
|
||||
// 카드/팝오버: 배경 + 흰색 30% 보간 (떠 있는 느낌)
|
||||
const card = mix(background, "#FFFFFF", 0.3)
|
||||
// 본문 텍스트: 가장 어두운 색을 0.20 이하로 보정 (대비 확보)
|
||||
const foreground = ensureDark(L3, 0.2)
|
||||
|
||||
// primary: 가장 어두운 색(L3)부터 채택. 0.45 이하로 다시 보정해서 흰 텍스트 가독성 확보.
|
||||
// 채도 우선이 아니라 명도 우선 — 사용자 지시(2026-05-08).
|
||||
const primary = ensureDark(L3, 0.45)
|
||||
const primaryForeground = "#FAFAFA"
|
||||
|
||||
// secondary, muted, accent: L1·L2 보간으로 톤 위계 만듦
|
||||
const secondary = L1
|
||||
const muted = mix(L0, L1, 0.5)
|
||||
const mutedForeground = L2
|
||||
const accent = mix(L1, L2, 0.3)
|
||||
const border = L1
|
||||
const sidebar = mix(L0, L1, 0.25)
|
||||
|
||||
return {
|
||||
background,
|
||||
foreground,
|
||||
card,
|
||||
cardForeground: foreground,
|
||||
popover: card,
|
||||
popoverForeground: foreground,
|
||||
primary,
|
||||
primaryForeground,
|
||||
secondary,
|
||||
secondaryForeground: foreground,
|
||||
muted,
|
||||
mutedForeground,
|
||||
accent,
|
||||
accentForeground: foreground,
|
||||
destructive: DESTRUCTIVE,
|
||||
border,
|
||||
input: border,
|
||||
ring: primary,
|
||||
sidebar,
|
||||
sidebarForeground: foreground,
|
||||
sidebarPrimary: primary,
|
||||
sidebarPrimaryForeground: primaryForeground,
|
||||
sidebarAccent: accent,
|
||||
sidebarAccentForeground: foreground,
|
||||
sidebarBorder: border,
|
||||
sidebarRing: primary,
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── CSS 생성 ─────────────────────────
|
||||
|
||||
function paletteToCss(id: string, t: DerivedTokens): string {
|
||||
const body = `--background:${t.background};--foreground:${t.foreground};--card:${t.card};--card-foreground:${t.cardForeground};--popover:${t.popover};--popover-foreground:${t.popoverForeground};--primary:${t.primary};--primary-foreground:${t.primaryForeground};--secondary:${t.secondary};--secondary-foreground:${t.secondaryForeground};--muted:${t.muted};--muted-foreground:${t.mutedForeground};--accent:${t.accent};--accent-foreground:${t.accentForeground};--destructive:${t.destructive};--border:${t.border};--input:${t.input};--ring:${t.ring};--sidebar:${t.sidebar};--sidebar-foreground:${t.sidebarForeground};--sidebar-primary:${t.sidebarPrimary};--sidebar-primary-foreground:${t.sidebarPrimaryForeground};--sidebar-accent:${t.sidebarAccent};--sidebar-accent-foreground:${t.sidebarAccentForeground};--sidebar-border:${t.sidebarBorder};--sidebar-ring:${t.sidebarRing};`
|
||||
// 1) :not(.dark) — 라이트모드에서 팔레트 토큰 적용. globals.css 의 .dark grayscale 블록은 그대로 살림.
|
||||
// 2) .dark .theme-light — 다크모드여도 이 class 붙은 subtree(채팅 버블 영역)만 라이트 토큰 유지.
|
||||
// <html>에 data-theme + .dark 둘 다 붙어 있어서 자식 .theme-light 를 이렇게 되살림.
|
||||
return `[data-theme="${id}"]:not(.dark){${body}}[data-theme="${id}"].dark .theme-light{${body}}`
|
||||
}
|
||||
|
||||
// 전체 테마 CSS를 한 번에 생성. 모듈 로드 시 1회 실행.
|
||||
export const ALL_THEMES_CSS: string = PALETTES.map((p) =>
|
||||
paletteToCss(p.id, p.id === "clean-blue" ? CLEAN_BLUE_TOKENS : derivePaletteTokens(p.swatch))
|
||||
).join("")
|
||||
|
||||
// 미리보기 카드용 — id로 derived tokens 조회
|
||||
export function getDerivedTokens(id: string): DerivedTokens | null {
|
||||
if (id === "clean-blue") return { ...CLEAN_BLUE_TOKENS }
|
||||
const p = PALETTES.find((x) => x.id === id)
|
||||
return p ? derivePaletteTokens(p.swatch) : null
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// 팔레트 목록 — 클린 블루의 고정 토큰은 derive.ts에서 정의.
|
||||
// 각 swatch는 스크린샷 위→아래 순서. 실제 shadcn 토큰은 derive.ts에서 자동 생성.
|
||||
// 새 팔레트 추가/수정 시 이 배열만 건드리면 됨.
|
||||
|
||||
export type RawPalette = {
|
||||
id: string
|
||||
name: string
|
||||
swatch: [string, string, string, string]
|
||||
}
|
||||
|
||||
export const PALETTES: RawPalette[] = [
|
||||
{
|
||||
id: "clean-blue",
|
||||
name: "클린 블루",
|
||||
swatch: ["#FFFFFF", "#F7F9FC", "#034EA2", "#242C39"],
|
||||
},
|
||||
// Sheet 1
|
||||
{ id: "dark-teal", name: "Dark Teal", swatch: ["#1A222B", "#2D3540", "#009E9F", "#ECECEC"] },
|
||||
{ id: "mint", name: "Mint", swatch: ["#DEF5F4", "#B8DDD8", "#87C5BF", "#5DAEA9"] },
|
||||
{ id: "steel-navy", name: "Steel Navy", swatch: ["#F5F5F7", "#DCE0E8", "#4A6FA5", "#14253E"] },
|
||||
{ id: "coral-cream", name: "Coral Cream", swatch: ["#FCE9D9", "#FBD2C9", "#FBB1AB", "#FA8B85"] },
|
||||
{ id: "dusty-rose", name: "Dusty Rose", swatch: ["#FFCBC9", "#FBE3E1", "#DDDED7", "#837C99"] },
|
||||
{ id: "warm-tan", name: "Warm Tan", swatch: ["#8B7B68", "#A38F7C", "#C9B8A4", "#EFE0CB"] },
|
||||
{
|
||||
id: "lavender-navy",
|
||||
name: "Lavender Navy",
|
||||
swatch: ["#EDE7F1", "#DBD3F0", "#9E9CC4", "#3F3D6A"],
|
||||
},
|
||||
{ id: "ocean-blue", name: "Ocean Blue", swatch: ["#1B2734", "#114870", "#1F86B6", "#BFDCEB"] },
|
||||
{ id: "steel-slate", name: "Steel Slate", swatch: ["#2D3845", "#4E5B6A", "#8C97A3", "#D7DBE2"] },
|
||||
{ id: "vivid-teal", name: "Vivid Teal", swatch: ["#20D0C7", "#1F2329", "#FF2A55", "#E4E2E0"] },
|
||||
{ id: "sunset", name: "Sunset", swatch: ["#FCE545", "#F4854A", "#BB346E", "#6F215C"] },
|
||||
// Sheet 2
|
||||
{ id: "tropical", name: "Tropical", swatch: ["#F37873", "#F8DA75", "#D8F39E", "#6BCFAF"] },
|
||||
{
|
||||
id: "soft-lavender",
|
||||
name: "Soft Lavender",
|
||||
swatch: ["#B0A8FA", "#A6BFFA", "#D8DCFA", "#DBE2EE"],
|
||||
},
|
||||
{ id: "mocha-cream", name: "Mocha Cream", swatch: ["#B5BEC8", "#ECE2CB", "#C7AC93", "#8B7565"] },
|
||||
{ id: "sky-cream", name: "Sky Cream", swatch: ["#6595B5", "#B7CEDC", "#CFD2D6", "#F1ECDF"] },
|
||||
{ id: "bubblegum", name: "Bubblegum", swatch: ["#B6D9EB", "#B2A3D7", "#F8B5CB", "#FAF1AF"] },
|
||||
{ id: "coral-mint", name: "Coral Mint", swatch: ["#F8B0AC", "#FBE3D9", "#C7E8DD", "#4FB6A8"] },
|
||||
{ id: "nude-tan", name: "Nude Tan", swatch: ["#FBE3CF", "#EDC3AC", "#DAA48E", "#B9876C"] },
|
||||
{ id: "mocha-beige", name: "Mocha Beige", swatch: ["#8E7A65", "#B59E84", "#D7C4AC", "#ECE4D5"] },
|
||||
{ id: "mocha-nude", name: "Mocha Nude", swatch: ["#74564A", "#BD8F7A", "#E0BBA5", "#F2D9C9"] },
|
||||
{ id: "sage", name: "Sage", swatch: ["#ECE6D8", "#B8C5A8", "#D7DDC8", "#6F8364"] },
|
||||
{ id: "vivid-navy", name: "Vivid Navy", swatch: ["#2F4660", "#2EBABE", "#F4F4F6", "#F8285E"] },
|
||||
{ id: "mauve", name: "Mauve", swatch: ["#F8EFE5", "#BC9C9F", "#835C66", "#432F3A"] },
|
||||
// Sheet 3 (2026-05-08 추가)
|
||||
{
|
||||
id: "charcoal-copper",
|
||||
name: "Charcoal Copper",
|
||||
swatch: ["#2C353D", "#3D4C49", "#90704F", "#D2C5AA"],
|
||||
},
|
||||
{
|
||||
id: "pink-mint-tea",
|
||||
name: "Pink Mint Tea",
|
||||
swatch: ["#F8D5D5", "#E0D5D3", "#D8E1DA", "#9CDFD7"],
|
||||
},
|
||||
{ id: "soft-blush", name: "Soft Blush", swatch: ["#FAE0E0", "#EAC8C7", "#DCE5E8", "#B7CCD1"] },
|
||||
{
|
||||
id: "mint-lavender",
|
||||
name: "Mint Lavender",
|
||||
swatch: ["#D7F5F0", "#C5D2EC", "#B5A6E0", "#B58CD3"],
|
||||
},
|
||||
{ id: "steel-cream", name: "Steel Cream", swatch: ["#F1EBDB", "#DDD0BC", "#A4B7C3", "#6F8EA8"] },
|
||||
{ id: "forest-sage", name: "Forest Sage", swatch: ["#E0E9C8", "#9FC195", "#5C9362", "#3A4D35"] },
|
||||
{ id: "mauve-blush", name: "Mauve Blush", swatch: ["#866C6E", "#C4A6A8", "#DEC8C8", "#F2DCDB"] },
|
||||
{ id: "cream-rose", name: "Cream Rose", swatch: ["#F5EEE0", "#F0DDDB", "#E9C7C5", "#C68F88"] },
|
||||
{ id: "sky-whisper", name: "Sky Whisper", swatch: ["#F2F5F4", "#D6E5EF", "#B6D6EB", "#5C9CCF"] },
|
||||
{ id: "slate-sand", name: "Slate Sand", swatch: ["#7C98AC", "#A6B7C2", "#DAD7CF", "#E6DAC1"] },
|
||||
{ id: "coral-plum", name: "Coral Plum", swatch: ["#E47077", "#A75873", "#574460", "#1F4E62"] },
|
||||
{ id: "pale-teal", name: "Pale Teal", swatch: ["#B7CFCB", "#CFE0DC", "#E2EBE6", "#E5E5DC"] },
|
||||
// Sheet 4 (2026-05-08 추가) — Pantone Color of the Year 2021 조합
|
||||
{
|
||||
id: "pantone-2021",
|
||||
name: "Pantone 2021",
|
||||
swatch: ["#939597", "#F2DD52", "#56585A", "#F2A007"],
|
||||
},
|
||||
]
|
||||
|
||||
export const DEFAULT_THEME = "clean-blue"
|
||||
|
||||
export const PALETTE_BY_ID: Record<string, RawPalette> = Object.fromEntries(
|
||||
PALETTES.map((p) => [p.id, p])
|
||||
)
|
||||
|
||||
export function isValidThemeId(id: string | undefined | null): id is string {
|
||||
return !!id && id in PALETTE_BY_ID
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { DEFAULT_THEME, isValidThemeId } from "./palettes"
|
||||
|
||||
const STORAGE_KEY = "theme"
|
||||
|
||||
function readDomTheme(): string | null {
|
||||
if (typeof document === "undefined") return null
|
||||
return document.documentElement.dataset.theme ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 팔레트 + 변경 함수.
|
||||
* 변경 시 즉시 DOM(data-theme) + localStorage 동기화.
|
||||
* Vite SPA 환경 — 쿠키/SSR 없음. FOUC 방지는 index.html 인라인 스크립트가 담당.
|
||||
*/
|
||||
export function useTheme() {
|
||||
// 초기값: 인라인 스크립트가 박은 data-theme → localStorage → DEFAULT 순.
|
||||
const [theme, setThemeState] = useState<string>(() => {
|
||||
const dom = readDomTheme()
|
||||
if (isValidThemeId(dom)) return dom
|
||||
if (typeof window !== "undefined") {
|
||||
const ls = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (isValidThemeId(ls)) return ls
|
||||
}
|
||||
return DEFAULT_THEME
|
||||
})
|
||||
|
||||
// 다른 탭에서 팔레트 바꾸면 동기화
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === STORAGE_KEY && isValidThemeId(e.newValue)) {
|
||||
setThemeState(e.newValue)
|
||||
document.documentElement.dataset.theme = e.newValue
|
||||
}
|
||||
}
|
||||
window.addEventListener("storage", onStorage)
|
||||
return () => window.removeEventListener("storage", onStorage)
|
||||
}, [])
|
||||
|
||||
const setTheme = useCallback((id: string) => {
|
||||
if (!isValidThemeId(id)) return
|
||||
setThemeState(id)
|
||||
// 즉시 DOM 반영 — 화면 전체 색이 바뀜
|
||||
document.documentElement.dataset.theme = id
|
||||
// localStorage — 다른 탭과 동기화 + 캐시 + 다음 로드 FOUC 방지
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, id)
|
||||
} catch {
|
||||
// 사용 불가 환경이면 무시
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { theme, setTheme }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils/cn"
|
||||
import { buttonVariants } from "@/shared/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-1/2 left-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils/cn"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 leading-none font-medium tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils/cn"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils/cn"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-1/2 left-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg leading-none font-semibold tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils/cn"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("bg-muted -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils/cn"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"border-input bg-background ring-offset-background file:text-foreground placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-base file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cn } from "@/lib/utils/cn"
|
||||
|
||||
export const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = "Label"
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils/cn"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,7 @@
|
||||
import { cn } from "@/lib/utils/cn"
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("bg-muted animate-pulse rounded-md", className)} {...props} />
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils/cn"
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[80px] w-full rounded-md border px-3 py-2 text-base focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils/cn"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 origin-[--radix-tooltip-content-transform-origin] overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
Reference in New Issue
Block a user