Initial Commit

This commit is contained in:
2026-09-16 17:22:14 +09:00
commit 858ee9e9da
335 changed files with 123898 additions and 0 deletions
@@ -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
}
}
+10
View File
@@ -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()
})
})
+87
View File
@@ -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()
}
+192
View File
@@ -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
}
+91
View File
@@ -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
}
+54
View File
@@ -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 }
}
+115
View File
@@ -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,
}
+49
View File
@@ -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 }
+48
View File
@@ -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 }
+104
View File
@@ -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,
}
+183
View File
@@ -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,
}
+22
View File
@@ -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 }
+18
View File
@@ -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"
+24
View File
@@ -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 }
+7
View File
@@ -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 }
+21
View File
@@ -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 }
+28
View File
@@ -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 }