feat: initialize Nuxt.js frontend application with Docker setup, global styling, and authentication pages.

This commit is contained in:
Missez 2026-02-05 13:43:16 +07:00
parent cf37d7371c
commit dfcabe306c
7 changed files with 238 additions and 12 deletions

View file

@ -0,0 +1,37 @@
# Dependencies
node_modules
# Build output
.output
.nuxt
dist
# Development files
.env
.env.local
*.log
# IDE & OS files
.vscode
.idea
*.swp
*.swo
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Docker files (prevent recursive copying)
Dockerfile*
docker-compose*.yml
.dockerignore
# Documentation
*.md
!README.md
# Test files
coverage
.nyc_output

View file

@ -0,0 +1,55 @@
# ================================
# Build Stage
# ================================
FROM node:20-alpine AS builder
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# ================================
# Production Stage
# ================================
FROM node:20-alpine AS production
# Set working directory
WORKDIR /app
# Set environment to production
ENV NODE_ENV=production
# Create non-root user for security
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nuxtjs
# Copy package files for preview command
COPY --from=builder --chown=nuxtjs:nodejs /app/package*.json ./
COPY --from=builder --chown=nuxtjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nuxtjs:nodejs /app/.nuxt ./.nuxt
COPY --from=builder --chown=nuxtjs:nodejs /app/.output ./.output
# Switch to non-root user
USER nuxtjs
# Expose port
EXPOSE 3001
# Set default environment variables
ENV HOST=0.0.0.0
ENV PORT=3001
ENV NUXT_HOST=0.0.0.0
ENV NUXT_PORT=3001
# Start the application using preview command
CMD ["npm", "run", "preview"]

View file

@ -7,3 +7,59 @@
<script setup lang="ts">
// This is the main app entry point
</script>
<style>
/* Global font override for production build consistency */
:root {
--q-body-font-size: 16px !important;
font-size: 16px !important;
}
html {
font-size: 16px !important;
}
body,
body.q-body--force-scrollbar-y,
.q-body--force-scrollbar-y {
font-size: 16px !important;
font-family: 'Prompt', 'Sarabun', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
}
/* Force Quasar components to use correct font size */
.q-page,
.q-layout,
.q-drawer,
.q-header,
.q-footer,
.q-card,
.q-card__section,
.q-item,
.q-item__label,
.q-btn,
.q-table,
.q-tabs,
.q-tab,
.q-field,
.q-field__native,
.q-input,
.q-select {
font-size: inherit !important;
font-family: inherit !important;
}
/* Typography sizes */
h1, .text-h1 { font-size: 2rem !important; }
h2, .text-h2 { font-size: 1.5rem !important; }
h3, .text-h3 { font-size: 1.25rem !important; }
h4, .text-h4 { font-size: 1.15rem !important; }
h5, .text-h5 { font-size: 1.125rem !important; }
h6, .text-h6 { font-size: 1rem !important; }
.text-body1 { font-size: 1rem !important; }
.text-body2 { font-size: 0.875rem !important; }
.text-caption { font-size: 0.75rem !important; }
.text-overline { font-size: 0.75rem !important; }
.text-subtitle1 { font-size: 1rem !important; }
.text-subtitle2 { font-size: 0.875rem !important; }
</style>

View file

@ -89,13 +89,30 @@
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
}
/* Base Styles */
/* Base Styles - Force consistent font across dev and production */
html {
font-size: 16px !important;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
font-family: 'Prompt', 'Sarabun', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
font-size: 1rem !important;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Override Quasar's default font settings for production build */
.q-page,
.q-layout,
.q-card,
.q-item,
.q-btn,
.q-table,
h1, h2, h3, h4, h5, h6,
p, span, div, a, label {
font-family: inherit !important;
}
/* Thai Language Support */
html[lang="th"] body {
font-family: 'Prompt', 'Sarabun', 'Inter', sans-serif;

View file

@ -0,0 +1,26 @@
version: "3.8"
services:
frontend_management:
build:
context: .
dockerfile: Dockerfile
container_name: elearning-frontend-management
ports:
- "3001:3001"
environment:
- NODE_ENV=production
- NUXT_PUBLIC_API_BASE_URL=${NUXT_PUBLIC_API_BASE_URL:-http://localhost:4000/api}
restart: unless-stopped
healthcheck:
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001" ]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- elearning-network
networks:
elearning-network:
driver: bridge

View file

@ -140,6 +140,15 @@ onMounted(() => {
navigateTo('/instructor');
}
}
// Load remembered email
if (typeof window !== 'undefined') {
const savedEmail = localStorage.getItem('rememberedEmail');
if (savedEmail) {
email.value = savedEmail;
rememberMe.value = true;
}
}
});
// Login form
@ -159,6 +168,13 @@ const handleLogin = async () => {
try {
const response = await authStore.login(email.value, password.value);
// Handle Remember Me
if (rememberMe.value) {
localStorage.setItem('rememberedEmail', email.value);
} else {
localStorage.removeItem('rememberedEmail');
}
$q.notify({
type: 'positive',
message: response.message || 'เข้าสู่ระบบสำเร็จ',

View file

@ -98,13 +98,13 @@
<!-- Prefix & Name -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<q-select
v-model="form.prefix"
v-model="selectedPrefix"
:options="prefixOptions"
label="คำนำหน้า *"
outlined
emit-value
map-options
:rules="[val => !!val.th || 'กรุณาเลือกคำนำหน้า']"
:rules="[val => !!val || 'กรุณาเลือกคำนำหน้า']"
hide-bottom-space
lazy-rules="ondemand"
/>
@ -183,6 +183,7 @@ const router = useRouter();
const loading = ref(false);
const showPassword = ref(false);
const confirmPassword = ref('');
const selectedPrefix = ref<string | null>(null);
// Form
const form = ref<RegisterInstructorRequest>({
@ -191,23 +192,41 @@ const form = ref<RegisterInstructorRequest>({
password: '',
first_name: '',
last_name: '',
prefix: { en: '', th: '' },
prefix: { th: '', en: '' }, // We construct this on submit
phone: ''
});
// Prefix options
const prefixMap: Record<string, { th: string; en: string }> = {
'mr': { th: 'นาย', en: 'Mr.' },
'mrs': { th: 'นาง', en: 'Mrs.' },
'ms': { th: 'นางสาว', en: 'Ms.' },
'dr': { th: 'ดร.', en: 'Dr.' },
'asst_prof': { th: 'ผศ.', en: 'Asst.Prof.' },
'assoc_prof': { th: 'รศ.', en: 'Assoc.Prof.' },
'prof': { th: 'ศ.', en: 'Prof.' }
};
const prefixOptions = [
{ label: 'นาย / Mr.', value: { th: 'นาย', en: 'Mr.' } },
{ label: 'นาง / Mrs.', value: { th: 'นาง', en: 'Mrs.' } },
{ label: 'นางสาว / Ms.', value: { th: 'นางสาว', en: 'Ms.' } },
{ label: 'ดร. / Dr.', value: { th: 'ดร.', en: 'Dr.' } },
{ label: 'ผศ. / Asst.Prof.', value: { th: 'ผศ.', en: 'Asst.Prof.' } },
{ label: 'รศ. / Assoc.Prof.', value: { th: 'รศ.', en: 'Assoc.Prof.' } },
{ label: 'ศ. / Prof.', value: { th: 'ศ.', en: 'Prof.' } }
{ label: 'นาย / Mr.', value: 'mr' },
{ label: 'นาง / Mrs.', value: 'mrs' },
{ label: 'นางสาว / Ms.', value: 'ms' },
{ label: 'ดร. / Dr.', value: 'dr' },
{ label: 'ผศ. / Asst.Prof.', value: 'asst_prof' },
{ label: 'รศ. / Assoc.Prof.', value: 'assoc_prof' },
{ label: 'ศ. / Prof.', value: 'prof' }
];
// Methods
const handleRegister = async () => {
if (!selectedPrefix.value) {
$q.notify({ type: 'warning', message: 'กรุณาเลือกคำนำหน้า', position: 'top' });
return;
}
// Map selected prefix string back to object
form.value.prefix = prefixMap[selectedPrefix.value];
loading.value = true;
try {
await authService.registerInstructor(form.value);