feat: claude nuxt 프로젝트 생성
This commit is contained in:
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"WebFetch(domain:nuxt.com)",
|
||||||
|
"WebFetch(domain:ui.shadcn.com)",
|
||||||
|
"WebFetch(domain:www.shadcn-vue.com)",
|
||||||
|
"Bash(pkill -f \"nuxt dev\" 2>/dev/null; echo \"완료\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Nuxt 개발 디렉토리
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules
|
||||||
|
.node_modules
|
||||||
|
|
||||||
|
# 빌드 결과물
|
||||||
|
dist
|
||||||
|
|
||||||
|
# 환경 변수 (민감한 정보 보호)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# 로그
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# 에디터
|
||||||
|
.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# TypeScript 생성 파일
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# 테스트 커버리지
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
8
.prettierrc
Normal file
8
.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"printWidth": 100,
|
||||||
|
"vueIndentScriptAndStyle": false
|
||||||
|
}
|
||||||
5
app/app.config.ts
Normal file
5
app/app.config.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default defineAppConfig({
|
||||||
|
// 앱 기본 정보
|
||||||
|
name: 'Nuxt 4 Starter',
|
||||||
|
description: 'Nuxt 4, TypeScript, TailwindCSS v4, shadcn-vue 기반 스타터 킷',
|
||||||
|
})
|
||||||
5
app/app.vue
Normal file
5
app/app.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
127
app/assets/css/main.css
Normal file
127
app/assets/css/main.css
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
/* 다크모드 변형 — .dark 클래스 기반 (@nuxtjs/color-mode 호환) */
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
/* TailwindCSS v4 테마 — shadcn-vue CSS 변수 연동 */
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 라이트 모드 — OKLCH 색상 값 (shadcn-vue init 생성) */
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 다크 모드 */
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 기본 전역 스타일 */
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/components/ui/Badge.vue
Normal file
37
app/components/ui/Badge.vue
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { type VariantProps, cva } from 'class-variance-authority'
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
|
||||||
|
secondary:
|
||||||
|
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
destructive:
|
||||||
|
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
variant?: VariantProps<typeof badgeVariants>['variant']
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="cn(badgeVariants({ variant }), props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
54
app/components/ui/Button.vue
Normal file
54
app/components/ui/Button.vue
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { type VariantProps, cva } from 'class-variance-authority'
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||||
|
outline:
|
||||||
|
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2',
|
||||||
|
sm: 'h-8 rounded-md px-3 text-xs',
|
||||||
|
lg: 'h-10 rounded-md px-8',
|
||||||
|
icon: 'h-9 w-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
variant?: VariantProps<typeof buttonVariants>['variant']
|
||||||
|
size?: VariantProps<typeof buttonVariants>['size']
|
||||||
|
class?: string
|
||||||
|
as?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
as: 'button',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="as"
|
||||||
|
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||||
|
:disabled="disabled"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
15
app/components/ui/Card.vue
Normal file
15
app/components/ui/Card.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="cn('rounded-xl border bg-card text-card-foreground shadow', props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
15
app/components/ui/CardContent.vue
Normal file
15
app/components/ui/CardContent.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="cn('p-6 pt-0', props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
15
app/components/ui/CardFooter.vue
Normal file
15
app/components/ui/CardFooter.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="cn('flex items-center p-6 pt-0', props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
15
app/components/ui/CardHeader.vue
Normal file
15
app/components/ui/CardHeader.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="cn('flex flex-col space-y-1.5 p-6', props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
15
app/components/ui/CardTitle.vue
Normal file
15
app/components/ui/CardTitle.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h3 :class="cn('font-semibold leading-none tracking-tight', props.class)">
|
||||||
|
<slot />
|
||||||
|
</h3>
|
||||||
|
</template>
|
||||||
33
app/components/ui/input/Input.vue
Normal file
33
app/components/ui/input/Input.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from "vue"
|
||||||
|
import { useVModel } from "@vueuse/core"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
defaultValue?: string | number
|
||||||
|
modelValue?: string | number
|
||||||
|
class?: HTMLAttributes["class"]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
(e: "update:modelValue", payload: string | number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modelValue = useVModel(props, "modelValue", emits, {
|
||||||
|
passive: true,
|
||||||
|
defaultValue: props.defaultValue,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<input
|
||||||
|
v-model="modelValue"
|
||||||
|
data-slot="input"
|
||||||
|
:class="cn(
|
||||||
|
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||||
|
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||||
|
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
</template>
|
||||||
1
app/components/ui/input/index.ts
Normal file
1
app/components/ui/input/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Input } from "./Input.vue"
|
||||||
46
app/error.vue
Normal file
46
app/error.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
error: {
|
||||||
|
statusCode: number
|
||||||
|
statusMessage: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const errorMessages: Record<number, string> = {
|
||||||
|
404: '페이지를 찾을 수 없습니다',
|
||||||
|
403: '접근 권한이 없습니다',
|
||||||
|
500: '서버 오류가 발생했습니다',
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorTitle = computed(
|
||||||
|
() => errorMessages[props.error.statusCode] ?? '오류가 발생했습니다'
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleError() {
|
||||||
|
clearError({ redirect: '/' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<div class="text-center px-4">
|
||||||
|
<p class="text-8xl font-bold text-muted-foreground/30 mb-4">
|
||||||
|
{{ error.statusCode }}
|
||||||
|
</p>
|
||||||
|
<h1 class="text-2xl font-semibold mb-2">{{ errorTitle }}</h1>
|
||||||
|
<p class="text-muted-foreground mb-8 max-w-md mx-auto">
|
||||||
|
{{ error.message || error.statusMessage }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
@click="handleError"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:arrow-left" class="w-4 h-4" />
|
||||||
|
홈으로 돌아가기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
68
app/layouts/default.vue
Normal file
68
app/layouts/default.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// @nuxtjs/color-mode 제공 컴포저블
|
||||||
|
const colorMode = useColorMode()
|
||||||
|
|
||||||
|
const isDark = computed(() => colorMode.value === 'dark')
|
||||||
|
|
||||||
|
function toggleColorMode() {
|
||||||
|
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex flex-col">
|
||||||
|
<!-- sticky 헤더 -->
|
||||||
|
<header
|
||||||
|
class="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||||
|
>
|
||||||
|
<div class="container mx-auto px-4 h-16 flex items-center justify-between">
|
||||||
|
<!-- 로고 -->
|
||||||
|
<NuxtLink
|
||||||
|
to="/"
|
||||||
|
class="font-bold text-xl tracking-tight hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
Nuxt 4 Starter
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- 네비게이션 -->
|
||||||
|
<nav class="hidden md:flex items-center gap-6">
|
||||||
|
<NuxtLink
|
||||||
|
to="/"
|
||||||
|
class="text-sm font-medium hover:text-primary transition-colors"
|
||||||
|
active-class="text-primary"
|
||||||
|
>
|
||||||
|
홈
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/about"
|
||||||
|
class="text-sm font-medium text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
active-class="text-primary"
|
||||||
|
>
|
||||||
|
소개
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- 다크모드 토글 -->
|
||||||
|
<button
|
||||||
|
@click="toggleColorMode"
|
||||||
|
class="p-2 rounded-md hover:bg-accent transition-colors cursor-pointer"
|
||||||
|
:aria-label="isDark ? '라이트 모드로 전환' : '다크 모드로 전환'"
|
||||||
|
>
|
||||||
|
<Icon :name="isDark ? 'lucide:sun' : 'lucide:moon'" class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 메인 콘텐츠 -->
|
||||||
|
<main class="flex-1">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- 푸터 -->
|
||||||
|
<footer class="border-t py-8">
|
||||||
|
<div class="container mx-auto px-4 text-center text-sm text-muted-foreground">
|
||||||
|
<p>Nuxt 4 Starter Kit — Built with Nuxt 4, TailwindCSS v4, shadcn-vue</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
7
app/lib/utils.ts
Normal file
7
app/lib/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { ClassValue } from "clsx"
|
||||||
|
import { clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
23
app/pages/about.vue
Normal file
23
app/pages/about.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
useSeoMeta({
|
||||||
|
title: '소개 — Nuxt 4 Starter Kit',
|
||||||
|
description: 'Nuxt 4 Starter Kit 소개 페이지',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-16 max-w-2xl">
|
||||||
|
<h1 class="text-3xl font-bold mb-4">소개</h1>
|
||||||
|
<p class="text-muted-foreground mb-8">이 페이지를 자유롭게 수정하세요.</p>
|
||||||
|
|
||||||
|
<Card class="p-6">
|
||||||
|
<CardHeader class="p-0 mb-4">
|
||||||
|
<CardTitle>Nuxt 4 Starter Kit</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-0 space-y-2 text-sm text-muted-foreground">
|
||||||
|
<p>최신 기술 스택으로 빠르게 웹 개발을 시작할 수 있는 스타터 킷입니다.</p>
|
||||||
|
<p>Nuxt 4, TypeScript, TailwindCSS v4, shadcn-vue를 기반으로 구성되어 있습니다.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
207
app/pages/index.vue
Normal file
207
app/pages/index.vue
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useCounterStore } from '~/stores/useCounterStore'
|
||||||
|
|
||||||
|
// SEO 메타 설정
|
||||||
|
useSeoMeta({
|
||||||
|
title: 'Nuxt 4 Starter Kit',
|
||||||
|
description: 'Nuxt 4, TypeScript, TailwindCSS v4, shadcn-vue 기반 스타터 킷',
|
||||||
|
})
|
||||||
|
|
||||||
|
const counter = useCounterStore()
|
||||||
|
|
||||||
|
// 기술 스택 목록
|
||||||
|
const techStack = [
|
||||||
|
{
|
||||||
|
icon: 'lucide:layers',
|
||||||
|
name: 'Nuxt 4',
|
||||||
|
description: 'Vue 기반 풀스택 프레임워크. app/ 디렉토리, 파일 기반 라우팅, SSR/SSG 지원.',
|
||||||
|
color: 'text-green-500',
|
||||||
|
bg: 'bg-green-500/10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'lucide:code-2',
|
||||||
|
name: 'TypeScript',
|
||||||
|
description: 'Strict 모드 타입 안전성. any 타입 금지, 컨텍스트별 별도 tsconfig.',
|
||||||
|
color: 'text-blue-500',
|
||||||
|
bg: 'bg-blue-500/10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'lucide:palette',
|
||||||
|
name: 'TailwindCSS v4',
|
||||||
|
description: 'CSS-first 설정. config 파일 없이 @theme으로 커스터마이징.',
|
||||||
|
color: 'text-cyan-500',
|
||||||
|
bg: 'bg-cyan-500/10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'lucide:component',
|
||||||
|
name: 'shadcn-vue',
|
||||||
|
description: '복사해서 소유하는 UI 컴포넌트. Radix Vue 기반, 완전한 커스터마이징 가능.',
|
||||||
|
color: 'text-purple-500',
|
||||||
|
bg: 'bg-purple-500/10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'lucide:database',
|
||||||
|
name: 'Pinia',
|
||||||
|
description: 'Vue 공식 상태 관리. Composition API 스타일, TypeScript 완벽 지원.',
|
||||||
|
color: 'text-yellow-500',
|
||||||
|
bg: 'bg-yellow-500/10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'lucide:moon',
|
||||||
|
name: '다크 모드',
|
||||||
|
description: '@nuxtjs/color-mode로 다크/라이트 모드 전환. shadcn CSS 변수 연동.',
|
||||||
|
color: 'text-slate-500',
|
||||||
|
bg: 'bg-slate-500/10',
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
// 버튼 variant 목록
|
||||||
|
const buttonVariants = ['default', 'secondary', 'outline', 'ghost', 'destructive', 'link'] as const
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4">
|
||||||
|
<!-- 히어로 섹션 -->
|
||||||
|
<section class="text-center py-20 mb-8">
|
||||||
|
<div class="flex flex-wrap items-center justify-center gap-2 mb-6">
|
||||||
|
<Badge>Nuxt 4</Badge>
|
||||||
|
<Badge variant="secondary">TypeScript</Badge>
|
||||||
|
<Badge variant="secondary">TailwindCSS v4</Badge>
|
||||||
|
<Badge variant="outline">shadcn-vue</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight mb-6">
|
||||||
|
Nuxt 4 Starter Kit
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="text-lg sm:text-xl text-muted-foreground max-w-2xl mx-auto mb-10">
|
||||||
|
최신 기술 스택으로 빠르게 웹 개발을 시작하세요.
|
||||||
|
<br class="hidden sm:block" />
|
||||||
|
Nuxt 4 + TypeScript + TailwindCSS v4 + shadcn-vue
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3 justify-center">
|
||||||
|
<Button size="lg">
|
||||||
|
<Icon name="lucide:rocket" class="w-4 h-4 mr-2" />
|
||||||
|
시작하기
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="lg">
|
||||||
|
<Icon name="lucide:book-open" class="w-4 h-4 mr-2" />
|
||||||
|
문서 보기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 기술 스택 섹션 -->
|
||||||
|
<section class="mb-16">
|
||||||
|
<h2 class="text-2xl font-semibold text-center mb-2">기술 스택</h2>
|
||||||
|
<p class="text-muted-foreground text-center mb-8">프로덕션 레디 기술 스택으로 구성</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<Card
|
||||||
|
v-for="tech in techStack"
|
||||||
|
:key="tech.name"
|
||||||
|
class="p-6 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<CardHeader class="p-0 mb-4 flex-row items-center gap-3">
|
||||||
|
<div :class="['p-2 rounded-lg', tech.bg]">
|
||||||
|
<Icon :name="tech.icon" :class="['w-5 h-5', tech.color]" />
|
||||||
|
</div>
|
||||||
|
<CardTitle class="text-base">{{ tech.name }}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-0">
|
||||||
|
<p class="text-sm text-muted-foreground">{{ tech.description }}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 컴포넌트 쇼케이스 -->
|
||||||
|
<section class="mb-16">
|
||||||
|
<h2 class="text-2xl font-semibold text-center mb-2">컴포넌트 쇼케이스</h2>
|
||||||
|
<p class="text-muted-foreground text-center mb-8">shadcn-vue 기본 컴포넌트 예시</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- 버튼 variants -->
|
||||||
|
<Card class="p-6">
|
||||||
|
<CardHeader class="p-0 mb-4">
|
||||||
|
<CardTitle>Button Variants</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-0">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
v-for="variant in buttonVariants"
|
||||||
|
:key="variant"
|
||||||
|
:variant="variant"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{{ variant }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Badge variants -->
|
||||||
|
<Card class="p-6">
|
||||||
|
<CardHeader class="p-0 mb-4">
|
||||||
|
<CardTitle>Badge Variants</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-0">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Badge>default</Badge>
|
||||||
|
<Badge variant="secondary">secondary</Badge>
|
||||||
|
<Badge variant="outline">outline</Badge>
|
||||||
|
<Badge variant="destructive">destructive</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Pinia 카운터 데모 -->
|
||||||
|
<section class="mb-16">
|
||||||
|
<h2 class="text-2xl font-semibold text-center mb-2">Pinia 상태 관리</h2>
|
||||||
|
<p class="text-muted-foreground text-center mb-8">useCounterStore 예시</p>
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<Card class="p-8 text-center w-full max-w-sm">
|
||||||
|
<CardHeader class="p-0 mb-6">
|
||||||
|
<CardTitle>카운터</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-0 mb-6">
|
||||||
|
<p class="text-6xl font-bold tabular-nums">{{ counter.count }}</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter class="p-0 flex justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
@click="counter.decrement"
|
||||||
|
:disabled="counter.count <= 0"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:minus" class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" @click="counter.reset">초기화</Button>
|
||||||
|
<Button size="icon" @click="counter.increment">
|
||||||
|
<Icon name="lucide:plus" class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 다음 단계 안내 -->
|
||||||
|
<section class="mb-16">
|
||||||
|
<Card class="p-8 bg-muted/50">
|
||||||
|
<CardHeader class="p-0 mb-4">
|
||||||
|
<CardTitle class="text-xl text-center">새 컴포넌트 추가하기</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-0 text-center">
|
||||||
|
<p class="text-muted-foreground mb-4">shadcn-vue CLI로 컴포넌트를 추가하세요</p>
|
||||||
|
<code class="bg-background border rounded-md px-4 py-2 text-sm font-mono">
|
||||||
|
npx shadcn-vue@latest add [component]
|
||||||
|
</code>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
22
app/stores/useCounterStore.ts
Normal file
22
app/stores/useCounterStore.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
// 카운터 스토어 — Pinia Composition API 스타일 예시
|
||||||
|
export const useCounterStore = defineStore('counter', () => {
|
||||||
|
// 상태
|
||||||
|
const count = ref<number>(0)
|
||||||
|
|
||||||
|
// 액션
|
||||||
|
function increment() {
|
||||||
|
count.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrement() {
|
||||||
|
if (count.value > 0) count.value--
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
count.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return { count, increment, decrement, reset }
|
||||||
|
})
|
||||||
7
app/utils/index.ts
Normal file
7
app/utils/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
// shadcn-vue 표준 유틸: Tailwind 클래스 병합 (충돌 자동 해결)
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
21
components.json
Normal file
21
components.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://shadcn-vue.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"typescript": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/assets/css/main.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"composables": "@/composables"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
9
eslint.config.mjs
Normal file
9
eslint.config.mjs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// @ts-check
|
||||||
|
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||||
|
|
||||||
|
export default withNuxt({
|
||||||
|
rules: {
|
||||||
|
// 단일 단어 컴포넌트명 허용 (예: error.vue, index.vue)
|
||||||
|
'vue/multi-word-component-names': 'off',
|
||||||
|
},
|
||||||
|
})
|
||||||
39
nuxt.config.ts
Normal file
39
nuxt.config.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: '2025-07-15',
|
||||||
|
|
||||||
|
devtools: { enabled: true },
|
||||||
|
|
||||||
|
modules: [
|
||||||
|
'@nuxt/icon',
|
||||||
|
'@nuxtjs/color-mode',
|
||||||
|
'@pinia/nuxt',
|
||||||
|
],
|
||||||
|
|
||||||
|
// shadcn-vue UI 컴포넌트: pathPrefix: false → 'Button'으로 사용 (UiButton 아님)
|
||||||
|
// extensions: ['vue'] → index.ts 중복 등록 방지
|
||||||
|
components: [
|
||||||
|
{ path: '~/components/ui', pathPrefix: false, extensions: ['vue'] },
|
||||||
|
'~/components',
|
||||||
|
],
|
||||||
|
|
||||||
|
// 다크모드: suffix 없이 class 기반 (shadcn-vue 호환)
|
||||||
|
colorMode: {
|
||||||
|
classSuffix: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
// 글로벌 CSS (공식 TailwindCSS 가이드 경로)
|
||||||
|
css: ['./app/assets/css/main.css'],
|
||||||
|
|
||||||
|
// TailwindCSS v4 Vite 플러그인 (공식 가이드)
|
||||||
|
vite: {
|
||||||
|
plugins: [tailwindcss()],
|
||||||
|
},
|
||||||
|
|
||||||
|
// TypeScript strict 모드
|
||||||
|
typescript: {
|
||||||
|
strict: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
43
package.json
Normal file
43
package.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "nuxt4-starter",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"build": "nuxt build",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"typecheck": "nuxt typecheck"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nuxt/icon": "^1.12.0",
|
||||||
|
"@nuxtjs/color-mode": "^3.5.2",
|
||||||
|
"@pinia/nuxt": "^0.9.0",
|
||||||
|
"@vueuse/core": "^14.2.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-vue-next": "^0.575.0",
|
||||||
|
"nuxt": "^4.0.0",
|
||||||
|
"pinia": "^2.3.0",
|
||||||
|
"radix-vue": "^1.9.9",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"vue": "^3.5.0",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@iconify-json/lucide": "^1.2.93",
|
||||||
|
"@nuxt/eslint": "^1.0.0",
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"eslint": "^9.0.0",
|
||||||
|
"prettier": "^3.4.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
9034
pnpm-lock.yaml
generated
Normal file
9034
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
tsconfig.json
Normal file
8
tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.nuxt/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictNullChecks": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user