init frontend_management

This commit is contained in:
Missez 2026-01-12 16:49:58 +07:00
parent af58550f7f
commit 62812f2090
23 changed files with 13174 additions and 0 deletions

View file

@ -0,0 +1,5 @@
# API Configuration
API_BASE_URL=http://localhost:3001/api
# Application
NODE_ENV=development

24
frontend_management/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

View file

@ -0,0 +1,75 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

View file

@ -0,0 +1,9 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<script setup lang="ts">
// This is the main app entry point
</script>

View file

@ -0,0 +1,109 @@
/* Import Google Fonts */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Prompt:wght@400;500;600;700&family=Sarabun:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
/* CSS Variables for Design System */
:root {
/* Primary Colors */
--primary-50: #EFF6FF;
--primary-100: #DBEAFE;
--primary-200: #BFDBFE;
--primary-300: #93C5FD;
--primary-400: #60A5FA;
--primary-500: #3B82F6;
--primary-600: #2563EB;
--primary-700: #1D4ED8;
--primary-800: #1E40AF;
--primary-900: #1E3A8A;
/* Secondary Colors */
--secondary-500: #10B981;
--accent-500: #F59E0B;
--error-500: #EF4444;
/* Neutral Colors */
--gray-50: #F9FAFB;
--gray-100: #F3F4F6;
--gray-200: #E5E7EB;
--gray-300: #D1D5DB;
--gray-400: #9CA3AF;
--gray-500: #6B7280;
--gray-600: #4B5563;
--gray-700: #374151;
--gray-800: #1F2937;
--gray-900: #111827;
/* Dark Mode */
--dark-bg-primary: #0F172A;
--dark-bg-secondary: #1E293B;
--dark-bg-tertiary: #334155;
--dark-text-primary: #F1F5F9;
--dark-text-secondary: #CBD5E1;
/* Typography */
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
--text-5xl: 3rem;
/* Font Weights */
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
/* Spacing (8px base unit) */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
--space-16: 4rem;
--space-20: 5rem;
/* Border Radius */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-2xl: 1.5rem;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1);
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
}
/* Base Styles */
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Thai Language Support */
html[lang="th"] body {
font-family: 'Prompt', 'Sarabun', 'Inter', sans-serif;
}
/* Custom Utilities */
@layer utilities {
.text-balance {
text-wrap: balance;
}
}

View file

@ -0,0 +1,74 @@
<template>
<div class="min-h-screen bg-gray-50">
<!-- Sidebar -->
<aside class="fixed left-0 top-0 h-screen w-64 bg-white shadow-lg">
<div class="p-6">
<h2 class="text-xl font-bold text-primary-600">E-Learning</h2>
<p class="text-sm text-gray-500">Instructor Panel</p>
</div>
<nav class="px-4">
<NuxtLink
to="/instructor"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 shadow-md transition mb-2"
active-class="bg-primary-500 text-white hover:bg-primary-600 "
>
<span>📊</span>
<span>Dashboard</span>
</NuxtLink>
<NuxtLink
to="/instructor/courses"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-200 shadow-md transition mb-2"
active-class="bg-primary-500 text-white hover:bg-primary-600"
>
<span>📚</span>
<span>หลกสตร</span>
</NuxtLink>
<NuxtLink
to="/instructor/announcements"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-200 shadow-md transition mb-2"
active-class="bg-primary-500 text-white hover:bg-primary-600"
>
<span>📢</span>
<span>ประกาศ</span>
</NuxtLink>
<NuxtLink
to="/instructor/reports"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-200 shadow-md transition mb-2"
active-class="bg-primary-500 text-white hover:bg-primary-600"
>
<span>📈</span>
<span>รายงาน</span>
</NuxtLink>
</nav>
<div class="absolute bottom-0 left-0 right-0 p-4 border-t">
<button
@click="handleLogout"
class="w-full flex items-center gap-3 px-4 py-3 text-red-600 hover:bg-red-50 rounded-lg transition"
>
<span>🚪</span>
<span>ออกจากระบบ</span>
</button>
</div>
</aside>
<!-- Main Content -->
<main class="ml-64 p-8">
<slot />
</main>
</div>
</template>
<script setup lang="ts">
const authStore = useAuthStore();
const router = useRouter();
const handleLogout = () => {
authStore.logout();
router.push('/login');
};
</script>

View file

@ -0,0 +1,7 @@
export default defineNuxtRouteMiddleware((to, from) => {
const authStore = useAuthStore();
authStore.checkAuth();
if (!authStore.isAdmin) {
return navigateTo('/login');
}
});

View file

@ -0,0 +1,7 @@
export default defineNuxtRouteMiddleware((to, from) => {
const authStore = useAuthStore();
authStore.checkAuth();
if (!authStore.isAuthenticated) {
return navigateTo('/login');
}
});

View file

@ -0,0 +1,7 @@
export default defineNuxtRouteMiddleware((to, from) => {
const authStore = useAuthStore();
authStore.checkAuth();
if (!authStore.isInstructor) {
return navigateTo('/login');
}
});

View file

@ -0,0 +1,46 @@
export default defineNuxtConfig({
modules: [
'nuxt-quasar-ui',
'@pinia/nuxt',
'@nuxtjs/tailwindcss'
],
quasar: {
plugins: [
'Notify',
'Dialog',
'Loading',
'LocalStorage'
],
config: {
brand: {
primary: '#3B82F6',
secondary: '#10B981',
accent: '#F59E0B',
dark: '#1F2937',
positive: '#10B981',
negative: '#EF4444',
info: '#3B82F6',
warning: '#F59E0B'
}
}
},
css: [
'~/assets/css/main.css'
],
runtimeConfig: {
public: {
apiBase: process.env.API_BASE_URL || 'http://localhost:3001/api',
useMockData: process.env.USE_MOCK_DATA === 'true'
}
},
devtools: { enabled: true },
app: {
head: {
title: 'E-Learning System',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' }
]
}
}
});

12221
frontend_management/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,26 @@
{
"name": "frontend_management",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@pinia/nuxt": "^0.11.3",
"@quasar/extras": "^1.17.0",
"nuxt": "^3.20.2",
"pinia": "^3.0.4",
"quasar": "^2.18.6",
"vue": "^3.5.26",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
"@types/node": "^25.0.3",
"nuxt-quasar-ui": "^3.0.0"
}
}

View file

@ -0,0 +1,59 @@
<template>
<div>
<h1 class="text-3xl font-bold mb-6">Dashboard</h1>
<p class="text-gray-600 mb-8">นดอนร, {{ authStore.user?.fullName }}</p>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="card">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-600 text-sm">หลกสตรทงหมด</p>
<p class="text-3xl font-bold text-primary-600">5</p>
</div>
<q-icon name="school" size="48px" class="text-primary-200" />
</div>
</div>
<div class="card">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-600 text-sm">เรยนทงหมด</p>
<p class="text-3xl font-bold text-secondary-500">125</p>
</div>
<q-icon name="people" size="48px" class="text-secondary-200" />
</div>
</div>
<div class="card">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-600 text-sm">เรยนจบแล</p>
<p class="text-3xl font-bold text-accent-500">45</p>
</div>
<q-icon name="emoji_events" size="48px" class="text-accent-200" />
</div>
</div>
</div>
<!-- Recent Courses -->
<div class="card">
<h2 class="text-xl font-semibold mb-4">หลกสตรลาส</h2>
<div class="space-y-4">
<div class="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<div class="w-16 h-16 bg-primary-100 rounded-lg flex items-center justify-center">
<q-icon name="code" size="32px" class="text-primary-600" />
</div>
<div class="flex-1">
<h3 class="font-semibold">Python เบองต</h3>
<p class="text-sm text-gray-600">45 เรยน 8 บทเรยน</p>
</div>
<q-btn flat color="primary" label="ดูรายละเอียด" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'instructor',
middleware: 'instructor'
});
const authStore = useAuthStore();
</script>

View file

@ -0,0 +1,47 @@
<template>
<div class="container mx-auto px-4 py-8">
<h1 class="text-4xl font-bold text-primary-600 mb-4">
E-Learning Management System
</h1>
<p class="text-lg text-gray-600 mb-8">
ระบบจดการเรยนการสอนออนไลน
</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<q-card class="shadow-lg">
<q-card-section>
<div class="text-h6 text-primary-600">📚 Courses</div>
<p class="text-gray-600">ดการหลกสตรและเนอหา</p>
</q-card-section>
</q-card>
<q-card class="shadow-lg">
<q-card-section>
<div class="text-h6 text-secondary-500">👥 Students</div>
<p class="text-gray-600">ดการผเรยน</p>
</q-card-section>
</q-card>
<q-card class="shadow-lg">
<q-card-section>
<div class="text-h6 text-accent-500">📊 Reports</div>
<p class="text-gray-600">รายงานและสถ</p>
</q-card-section>
</q-card>
<q-btn
flat
color="primary"
label="login"
@click="handleLogin"
/>
</div>
</div>
</template>
<script setup lang="ts">
// Home page
const handleLogin = async () => {
await navigateTo('/login')
}
</script>

View file

@ -0,0 +1,106 @@
<template>
<div>
<!-- Header -->
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-bold text-gray-900">สวสด, อาจารยทดสอบ 👋</h1>
<p class="text-gray-600 mt-2">นดอนรบกลบสระบบ</p>
</div>
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center text-2xl">
👤
</div>
</div>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<q-card class="p-6 text-center">
<div class="text-4xl font-bold text-primary-600 mb-2">
{{ instructorStore.stats.totalCourses }}
</div>
<div class="text-gray-600">หลกสตรทงหมด</div>
</q-card>
<q-card class="p-6 text-center">
<div class="text-4xl font-bold text-secondary-600 mb-2">
{{ instructorStore.stats.totalStudents }}
</div>
<div class="text-gray-600">เรยนทงหมด</div>
</q-card>
<q-card class="p-6 text-center">
<div class="text-4xl font-bold text-accent-600 mb-2">
{{ instructorStore.stats.completedStudents }}
</div>
<div class="text-gray-600">เรยนจบแล</div>
</q-card>
</div>
<!-- Chart and Recent Courses -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Chart Placeholder -->
<q-card>
<q-card-section>
<h3 class="text-xl font-semibold mb-4">📊 สถสมครรวม (รายเดอน)</h3>
<div class="bg-gray-100 rounded-lg p-12 text-center text-gray-500">
[กราฟแสดงสถสมครรวม (รายเดอน)]
</div>
</q-card-section>
</q-card>
<!-- Recent Courses -->
<q-card>
<q-card-section>
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-semibold">📚 หลกสตรลาส</h3>
<q-btn
flat
color="primary"
label="ดูทั้งหมด"
@click="router.push('/instructor/courses')"
/>
</div>
<div class="space-y-4">
<q-card
v-for="course in instructorStore.recentCourses"
:key="course.id"
class="cursor-pointer hover:shadow-md transition"
@click="router.push(`/instructor/courses/${course.id}`)"
>
<q-card-section>
<div class="flex gap-4">
<div class="w-20 h-16 bg-primary-100 rounded-lg flex items-center justify-center text-3xl">
{{ course.icon }}
</div>
<div class="flex-1">
<div class="font-semibold text-gray-900">{{ course.title }}</div>
<div class="text-sm text-gray-600 mt-1">
{{ course.students }} เรยน {{ course.lessons }} บทเรยน
</div>
</div>
</div>
</q-card-section>
</q-card>
</div>
</q-card-section>
</q-card>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'instructor',
middleware: 'auth'
});
const instructorStore = useInstructorStore();
const router = useRouter();
// Fetch dashboard data on mount
onMounted(() => {
instructorStore.fetchDashboardData();
});
</script>

View file

@ -0,0 +1,93 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100">
<q-card class="w-full max-w-md p-8 shadow-xl">
<q-card-section>
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">E-Learning</h1>
<p class="text-gray-600 mt-2">เขาสระบบ</p>
</div>
<q-form @submit="handleLogin" class="space-y-4">
<q-input
v-model="email"
label="อีเมล"
type="email"
outlined
:rules="[val => !!val || 'กรุณากรอกอีเมล']"
>
<template v-slot:prepend>
<q-icon name="email" />
</template>
</q-input>
<q-input
v-model="password"
label="รหัสผ่าน"
:type="showPassword ? 'text' : 'password'"
outlined
:rules="[val => !!val || 'กรุณากรอกรหัสผ่าน']"
>
<template v-slot:prepend>
<q-icon name="lock" />
</template>
<template v-slot:append>
<q-icon
:name="showPassword ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="showPassword = !showPassword"
/>
</template>
</q-input>
<q-btn
type="submit"
color="primary"
label="เข้าสู่ระบบ"
class="w-full"
size="lg"
:loading="loading"
/>
</q-form>
<div class="mt-6 text-center text-sm text-gray-600">
<p>ทดสอบ: instructor@test.com / admin@test.com</p>
</div>
</q-card-section>
</q-card>
</div>
</template>
<script setup lang="ts">
import { useQuasar } from 'quasar';
definePageMeta({
layout: 'auth'
});
const $q = useQuasar();
const authStore = useAuthStore();
const router = useRouter();
const email = ref('');
const password = ref('');
const showPassword = ref(false);
const loading = ref(false);
const handleLogin = async () => {
loading.value = true;
try {
await authStore.login(email.value, password.value);
$q.notify({
type: 'positive',
message: 'เข้าสู่ระบบสำเร็จ',
position: 'top'
});
// Redirect based on role
if (authStore.isInstructor) {
router.push('/instructor');
} else if (authStore.isAdmin) {
router.push('/admin');
}
} catch (error) {
$q.notify({
type: 'negative',
message: 'อีเมลหรือรหัสผ่านไม่ถูกต้อง',
position: 'top'
});
} finally {
loading.value = false;
}
};
</script>

View file

@ -0,0 +1,74 @@
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig();
if (!config.public.useMockData) {
return; // ใช้ API จริง
}
// Mock Data
const mockUsers = [
{ id: '1', email: 'instructor@test.com', fullName: 'อาจารย์ทดสอบ', role: 'INSTRUCTOR' },
{ id: '2', email: 'admin@test.com', fullName: 'ผู้ดูแลระบบ', role: 'ADMIN' }
];
const mockCourses = [
{
id: '1',
title: 'Python เบื้องต้น',
description: 'เรียนรู้ Python จากพื้นฐาน',
instructorId: '1',
status: 'PUBLISHED',
thumbnail: '/images/python.jpg'
},
{
id: '2',
title: 'JavaScript สำหรับเว็บ',
description: 'พัฒนาเว็บด้วย JavaScript',
instructorId: '1',
status: 'DRAFT',
thumbnail: '/images/javascript.jpg'
}
];
// Mock API Functions
const mockApi = {
// Auth
login: async (email: string, password: string) => {
await new Promise(resolve => setTimeout(resolve, 500)); // Simulate delay
const user = mockUsers.find(u => u.email === email);
if (user) {
return {
token: 'mock-jwt-token',
user
};
}
throw new Error('Invalid credentials');
},
// Courses
getCourses: async () => {
await new Promise(resolve => setTimeout(resolve, 300));
return mockCourses;
},
getCourse: async (id: string) => {
await new Promise(resolve => setTimeout(resolve, 300));
return mockCourses.find(c => c.id === id);
},
createCourse: async (data: any) => {
await new Promise(resolve => setTimeout(resolve, 500));
const newCourse = {
id: String(mockCourses.length + 1),
...data,
status: 'DRAFT'
};
mockCourses.push(newCourse);
return newCourse;
},
// Users
getUsers: async () => {
await new Promise(resolve => setTimeout(resolve, 300));
return mockUsers;
}
};
return {
provide: {
mockApi
}
};
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1,2 @@
User-Agent: *
Disallow:

View file

@ -0,0 +1,77 @@
import { defineStore } from 'pinia';
interface User {
id: string;
email: string;
fullName: string;
role: 'INSTRUCTOR' | 'ADMIN' | 'STUDENT';
}
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null as User | null,
token: null as string | null,
isAuthenticated: false
}),
getters: {
isInstructor: (state) => state.user?.role === 'INSTRUCTOR',
isAdmin: (state) => state.user?.role === 'ADMIN',
isStudent: (state) => state.user?.role === 'STUDENT'
},
actions: {
async login(email: string, password: string) {
// TODO: Replace with real API call
// const { $api } = useNuxtApp();
// const response = await $api('/auth/login', {
// method: 'POST',
// body: { email, password }
// });
// Mock login for development
const mockUser: User = {
id: '1',
email: email,
fullName: 'อาจารย์ทดสอบ',
role: 'INSTRUCTOR'
};
this.token = 'mock-jwt-token';
this.user = mockUser;
this.isAuthenticated = true;
// Save to localStorage
if (process.client) {
localStorage.setItem('token', this.token);
localStorage.setItem('user', JSON.stringify(this.user));
}
return { token: this.token, user: this.user };
},
logout() {
this.user = null;
this.token = null;
this.isAuthenticated = false;
if (process.client) {
localStorage.removeItem('token');
localStorage.removeItem('user');
}
},
checkAuth() {
if (process.client) {
const token = localStorage.getItem('token');
const user = localStorage.getItem('user');
if (token && user) {
this.token = token;
this.user = JSON.parse(user);
this.isAuthenticated = true;
}
}
}
}
});

View file

@ -0,0 +1,60 @@
import { defineStore } from 'pinia';
interface Course {
id: string;
title: string;
students: number;
lessons: number;
icon: string;
}
interface DashboardStats {
totalCourses: number;
totalStudents: number;
completedStudents: number;
}
export const useInstructorStore = defineStore('instructor', {
state: () => ({
stats: {
totalCourses: 5,
totalStudents: 125,
completedStudents: 45
} as DashboardStats,
recentCourses: [
{
id: '1',
title: 'Python เบื้องต้น',
students: 45,
lessons: 8,
icon: '📘'
},
{
id: '2',
title: 'JavaScript สำหรับเว็บ',
students: 32,
lessons: 12,
icon: '📗'
}
] as Course[]
}),
getters: {
getDashboardStats: (state) => state.stats,
getRecentCourses: (state) => state.recentCourses
},
actions: {
async fetchDashboardData() {
// TODO: Replace with real API call
// const { $api } = useNuxtApp();
// const data = await $api('/instructor/dashboard');
// this.stats = data.stats;
// this.recentCourses = data.recentCourses;
// Using mock data for now
console.log('Using mock data for instructor dashboard');
}
}
});

View file

@ -0,0 +1,42 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./components/**/*.{vue,js,ts}',
'./layouts/**/*.vue',
'./pages/**/*.vue',
'./plugins/**/*.{js,ts}',
'./app.vue'
],
theme: {
extend: {
colors: {
primary: {
50: '#EFF6FF',
100: '#DBEAFE',
200: '#BFDBFE',
300: '#93C5FD',
400: '#60A5FA',
500: '#3B82F6',
600: '#2563EB',
700: '#1D4ED8',
800: '#1E40AF',
900: '#1E3A8A'
},
secondary: {
500: '#10B981'
},
accent: {
500: '#F59E0B'
},
error: {
500: '#EF4444'
}
},
fontFamily: {
sans: ['Prompt', 'Sarabun', 'Inter', 'sans-serif'],
thai: ['Prompt', 'Sarabun', 'Inter', 'sans-serif']
}
}
},
plugins: []
}

View file

@ -0,0 +1,4 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}