feat: 스티그마타 초기 커밋

This commit is contained in:
hyoseung 2026-05-28 17:37:20 +09:00
commit 6935ea0a78
22 changed files with 4014 additions and 0 deletions

39
.gitignore vendored Normal file
View File

@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

42
README.md Normal file
View File

@ -0,0 +1,42 @@
# rpg-web
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```

1
env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>스티그마타</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2994
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "rpg-web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build"
},
"dependencies": {
"vue": "^3.5.32",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1",
"npm-run-all2": "^8.0.4",
"typescript": "~6.0.0",
"vite": "^8.0.8",
"vite-plugin-vue-devtools": "^8.1.1",
"vue-tsc": "^3.2.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

30
src/App.vue Normal file
View File

@ -0,0 +1,30 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
height: 100%;
background: #0a0a0f;
color: #e0d0ff;
}
#app {
height: 100vh;
display: flex;
flex-direction: column;
}
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0d0d1a; }
::-webkit-scrollbar-thumb { background: #2a1a4e; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #5522aa; }
</style>

51
src/api.ts Normal file
View File

@ -0,0 +1,51 @@
const BASE = import.meta.env.VITE_API_URL || ''
function authHeaders() {
const token = localStorage.getItem('rpg_token')
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
}
}
async function post(path: string, body?: object) {
const res = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: authHeaders(),
body: body ? JSON.stringify(body) : undefined,
})
const data = await res.json()
if (!res.ok) throw new Error(data.message || '오류가 발생했습니다.')
return data
}
async function get(path: string) {
const res = await fetch(`${BASE}${path}`, { headers: authHeaders() })
const data = await res.json()
if (!res.ok) throw new Error(data.message || '오류가 발생했습니다.')
return data
}
export const api = {
register: (username: string, password: string) =>
post('/api/auth/register', { username, password }),
login: (username: string, password: string) =>
post('/api/auth/login', { username, password }),
me: () => get('/api/auth/me'),
rpgStart: () => post('/api/web/rpg/start'),
rpgAction: (choice: number) =>
post('/api/web/rpg/action', { choice }),
rpgStatus: () => get('/api/web/rpg/status'),
rpgReset: () => post('/api/web/rpg/reset'),
}
export interface RpgResult {
text: string
choices: { text: string; [key: string]: unknown }[]
}

86
src/assets/base.css Normal file
View File

@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

1
src/assets/logo.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

6
src/assets/main.css Normal file
View File

@ -0,0 +1,6 @@
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
padding: 0;
}

11
src/main.ts Normal file
View File

@ -0,0 +1,11 @@
import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.mount('#app')

20
src/router/index.ts Normal file
View File

@ -0,0 +1,20 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import LoginView from '../views/LoginView.vue'
import GameView from '../views/GameView.vue'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/', redirect: '/login' },
{ path: '/login', name: 'login', component: LoginView },
{ path: '/game', name: 'game', component: GameView, meta: { requiresAuth: true } },
],
})
router.beforeEach((to) => {
const token = localStorage.getItem('rpg_token')
if (to.meta.requiresAuth && !token) return '/login'
if (to.path === '/login' && token) return '/game'
})
export default router

409
src/views/GameView.vue Normal file
View File

@ -0,0 +1,409 @@
<template>
<div class="game-wrap">
<header class="game-header">
<span class="header-title"> 스티그마타</span>
<div class="header-right">
<span class="username">{{ username }}</span>
<button class="btn-status" @click="showStatus = !showStatus" title="캐릭터 상태">📊</button>
<button class="btn-reset" @click="confirmReset" title="초기화">🔄</button>
<button class="btn-logout" @click="logout" title="로그아웃">🚪</button>
</div>
</header>
<div class="status-panel" v-if="showStatus">
<pre class="status-text">{{ statusText || '상태를 불러오는 중...' }}</pre>
</div>
<main class="game-main">
<div class="story-area" ref="storyRef">
<div v-for="(entry, i) in history" :key="i" class="story-entry" :class="entry.type">
<pre class="story-text">{{ entry.text }}</pre>
</div>
<div v-if="loading" class="loading-dots">
<span></span><span></span><span></span>
</div>
</div>
<div class="choices-area" v-if="!loading && choices.length > 0">
<button
v-for="(choice, i) in choices"
:key="i"
class="choice-btn"
@click="selectChoice(i + 1)"
>
<span class="choice-num">{{ numEmoji(i) }}</span>
<span class="choice-text">{{ choice.text }}</span>
</button>
</div>
<div class="restart-area" v-if="!loading && choices.length === 0 && history.length > 0">
<button class="btn-restart" @click="startGame">다시 시작</button>
</div>
</main>
<div class="modal-overlay" v-if="resetConfirm" @click.self="resetConfirm = false">
<div class="modal">
<p> 캐릭터를 초기화하면 모든 진행 상황이 삭제됩니다. 계속하시겠습니까?</p>
<div class="modal-btns">
<button @click="doReset" class="btn-danger">초기화</button>
<button @click="resetConfirm = false">취소</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick, watch } from 'vue'
import { useRouter } from 'vue-router'
import { api, type RpgResult } from '../api'
const router = useRouter()
const username = ref(localStorage.getItem('rpg_username') || '')
const history = ref<{ text: string; type: 'story' | 'choice' }[]>([])
const choices = ref<{ text: string }[]>([])
const loading = ref(false)
const showStatus = ref(false)
const statusText = ref('')
const resetConfirm = ref(false)
const storyRef = ref<HTMLElement>()
const NUM_EMOJIS = ['1⃣','2⃣','3⃣','4⃣','5⃣','6⃣','7⃣','8⃣']
function numEmoji(i: number) { return NUM_EMOJIS[i] || `${i+1}.` }
function scrollDown() {
nextTick(() => {
if (storyRef.value) storyRef.value.scrollTop = storyRef.value.scrollHeight
})
}
function applyResult(result: RpgResult) {
history.value.push({ text: result.text, type: 'story' })
choices.value = result.choices || []
scrollDown()
}
async function startGame() {
loading.value = true
history.value = []
choices.value = []
try {
const result = await api.rpgStart()
applyResult(result)
} catch (e: any) {
history.value.push({ text: `${e.message}`, type: 'story' })
} finally {
loading.value = false
}
}
async function selectChoice(choice: number) {
const selected = choices.value[choice - 1]
if (!selected) return
history.value.push({ text: `${selected.text}`, type: 'choice' })
choices.value = []
loading.value = true
scrollDown()
try {
const result = await api.rpgAction(choice)
applyResult(result)
} catch (e: any) {
history.value.push({ text: `${e.message}`, type: 'story' })
choices.value = []
} finally {
loading.value = false
}
}
async function loadStatus() {
try {
const res = await api.rpgStatus()
statusText.value = res.text
} catch {
statusText.value = '상태를 불러올 수 없습니다.'
}
}
watch(showStatus, (v) => { if (v) loadStatus() })
function confirmReset() { resetConfirm.value = true }
async function doReset() {
resetConfirm.value = false
loading.value = true
try {
await api.rpgReset()
await startGame()
} finally {
loading.value = false
}
}
function logout() {
localStorage.removeItem('rpg_token')
localStorage.removeItem('rpg_username')
router.push('/login')
}
onMounted(startGame)
</script>
<style scoped>
.game-wrap {
display: flex;
flex-direction: column;
height: 100vh;
background: #0a0a0f;
color: #e0d0ff;
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
}
.game-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: #13131f;
border-bottom: 1px solid #2a1a4e;
flex-shrink: 0;
}
.header-title {
font-size: 16px;
font-weight: 700;
color: #c8a8ff;
letter-spacing: 1px;
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.username {
font-size: 13px;
color: #8877bb;
margin-right: 4px;
}
.game-header button {
background: transparent;
border: 1px solid #2a1a4e;
border-radius: 6px;
color: #8877bb;
font-size: 16px;
width: 34px;
height: 34px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.game-header button:hover {
background: #2a1a4e;
color: #c8a8ff;
}
.status-panel {
background: #0d0d1a;
border-bottom: 1px solid #2a1a4e;
padding: 12px 20px;
max-height: 200px;
overflow-y: auto;
flex-shrink: 0;
}
.status-text {
margin: 0;
font-size: 13px;
color: #c8a8ff;
white-space: pre-wrap;
font-family: inherit;
}
.game-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
max-width: 700px;
width: 100%;
margin: 0 auto;
padding: 0 16px;
}
.story-area {
flex: 1;
overflow-y: auto;
padding: 20px 0 12px;
display: flex;
flex-direction: column;
gap: 16px;
}
.story-entry.story .story-text {
background: #13131f;
border: 1px solid #2a1a4e;
border-radius: 10px;
padding: 16px 18px;
margin: 0;
font-size: 15px;
line-height: 1.8;
color: #e0d0ff;
white-space: pre-wrap;
font-family: inherit;
}
.story-entry.choice .story-text {
text-align: right;
margin: 0;
font-size: 13px;
color: #7744cc;
font-style: italic;
white-space: pre-wrap;
font-family: inherit;
padding: 4px 4px;
}
.loading-dots {
display: flex;
gap: 6px;
padding: 8px 18px;
}
.loading-dots span {
width: 8px;
height: 8px;
background: #5522aa;
border-radius: 50%;
animation: bounce 1.2s infinite;
}
.loading-dots span:nth-child(2) { animation-delay: 0.2s; }
.loading-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
.choices-area {
padding: 12px 0 16px;
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
}
.choice-btn {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
background: #13131f;
border: 1px solid #2a1a4e;
border-radius: 10px;
padding: 13px 16px;
color: #e0d0ff;
font-size: 14px;
cursor: pointer;
text-align: left;
transition: all 0.2s;
font-family: inherit;
}
.choice-btn:hover {
background: #1e1030;
border-color: #7744cc;
color: #c8a8ff;
transform: translateX(2px);
}
.choice-btn:active {
transform: translateX(0);
background: #2a1a4e;
}
.choice-num {
font-size: 18px;
flex-shrink: 0;
}
.choice-text {
line-height: 1.4;
}
.restart-area {
padding: 16px 0;
text-align: center;
}
.btn-restart {
background: linear-gradient(135deg, #5522aa, #7744cc);
border: none;
border-radius: 8px;
color: #fff;
padding: 12px 32px;
font-size: 15px;
font-weight: 700;
cursor: pointer;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 20px;
}
.modal {
background: #13131f;
border: 1px solid #2a1a4e;
border-radius: 12px;
padding: 28px 24px;
max-width: 360px;
text-align: center;
}
.modal p {
color: #e0d0ff;
font-size: 14px;
line-height: 1.7;
margin: 0 0 20px;
}
.modal-btns {
display: flex;
gap: 10px;
justify-content: center;
}
.modal-btns button {
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: 1px solid #2a1a4e;
background: #0d0d1a;
color: #8877bb;
transition: all 0.2s;
}
.modal-btns button:hover { background: #2a1a4e; color: #c8a8ff; }
.btn-danger {
background: #3d0a1a !important;
border-color: #7a1a2e !important;
color: #ff6677 !important;
}
.btn-danger:hover { background: #5a1020 !important; }
</style>

3
src/views/HomeView.vue Normal file
View File

@ -0,0 +1,3 @@
<template>
<div></div>
</template>

200
src/views/LoginView.vue Normal file
View File

@ -0,0 +1,200 @@
<template>
<div class="login-wrap">
<div class="login-box">
<div class="logo">
<span class="logo-icon"></span>
<h1>스티그마타</h1>
<p class="subtitle">🌑 영원한 잿빛 하늘 아래의 이야기</p>
</div>
<div class="tabs">
<button :class="{ active: mode === 'login' }" @click="mode = 'login'; error = ''">로그인</button>
<button :class="{ active: mode === 'register' }" @click="mode = 'register'; error = ''">회원가입</button>
</div>
<form @submit.prevent="submit">
<div class="field">
<label>닉네임</label>
<input v-model="username" type="text" placeholder="닉네임을 입력하세요" maxlength="20" autocomplete="username" />
</div>
<div class="field">
<label>비밀번호</label>
<input v-model="password" type="password" placeholder="비밀번호를 입력하세요" autocomplete="current-password" />
</div>
<p v-if="error" class="error">{{ error }}</p>
<button type="submit" class="btn-submit" :disabled="loading">
{{ loading ? '처리 중...' : mode === 'login' ? '로그인' : '회원가입' }}
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api'
const router = useRouter()
const mode = ref<'login' | 'register'>('login')
const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
async function submit() {
error.value = ''
if (!username.value.trim() || !password.value) {
error.value = '닉네임과 비밀번호를 입력해주세요.'
return
}
loading.value = true
try {
const res = mode.value === 'login'
? await api.login(username.value.trim(), password.value)
: await api.register(username.value.trim(), password.value)
localStorage.setItem('rpg_token', res.token)
localStorage.setItem('rpg_username', res.username)
router.push('/game')
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #0a0a0f;
background-image: radial-gradient(ellipse at top, #1a0a2e 0%, #0a0a0f 70%);
padding: 20px;
}
.login-box {
width: 100%;
max-width: 400px;
background: #13131f;
border: 1px solid #2a1a4e;
border-radius: 12px;
padding: 40px 32px;
box-shadow: 0 0 40px rgba(100, 0, 200, 0.15);
}
.logo {
text-align: center;
margin-bottom: 28px;
}
.logo-icon {
font-size: 48px;
display: block;
margin-bottom: 8px;
}
.logo h1 {
font-size: 22px;
font-weight: 700;
color: #c8a8ff;
margin: 0 0 6px;
letter-spacing: 1px;
}
.subtitle {
font-size: 13px;
color: #6655a0;
margin: 0;
}
.tabs {
display: flex;
gap: 0;
margin-bottom: 24px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #2a1a4e;
}
.tabs button {
flex: 1;
padding: 10px;
background: transparent;
border: none;
color: #6655a0;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.tabs button.active {
background: #2a1a4e;
color: #c8a8ff;
}
.field {
margin-bottom: 16px;
}
.field label {
display: block;
font-size: 13px;
color: #8877bb;
margin-bottom: 6px;
font-weight: 600;
}
.field input {
width: 100%;
background: #0d0d1a;
border: 1px solid #2a1a4e;
border-radius: 8px;
padding: 12px 14px;
color: #e0d0ff;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
}
.field input:focus {
border-color: #7744cc;
}
.field input::placeholder {
color: #3d3060;
}
.error {
color: #ff6677;
font-size: 13px;
margin: -8px 0 12px;
}
.btn-submit {
width: 100%;
padding: 13px;
background: linear-gradient(135deg, #5522aa, #7744cc);
border: none;
border-radius: 8px;
color: #fff;
font-size: 15px;
font-weight: 700;
cursor: pointer;
transition: opacity 0.2s;
margin-top: 8px;
}
.btn-submit:hover:not(:disabled) {
opacity: 0.85;
}
.btn-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

18
tsconfig.app.json Normal file
View File

@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
// Path mapping for cleaner imports.
"paths": {
"@/*": ["./src/*"]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}

11
tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

27
tsconfig.node.json Normal file
View File

@ -0,0 +1,27 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
// Most tools use transpilation instead of Node.js's native type-stripping.
// Bundler mode provides a smoother developer experience.
"module": "preserve",
"moduleResolution": "bundler",
// Include Node.js types and avoid accidentally including other `@types/*` packages.
"types": ["node"],
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
"noEmit": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}

18
vite.config.ts Normal file
View File

@ -0,0 +1,18 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})