App (Vue SPA) — 100% presentational, global design-token rebuild in style.css + polished App/Login/Chat/MyBoard/Personas/Setup/Training. No logic/API/route/i18n changes (verified: build clean, 4/4 vitest, independent review PASS 5/5). website/ — new self-contained static marketing landing site (TH-primary + EN toggle, CSS chat mockup, Hallmark theme), verified responsive at 320-768 (no horizontal scroll). Pricing uses placeholders pending owner. Docs: engineering-log 2026-08-19 + HANDOFF updated. NOT pushed (push to main auto-deploys to EasyPanel production; operator approval + JWT_SECRET check required before deploy).
162 lines
6.4 KiB
Vue
162 lines
6.4 KiB
Vue
<template>
|
|
<div class="app">
|
|
<nav v-if="auth.user" class="topnav">
|
|
<router-link to="/" class="brand">
|
|
<span class="brand-mark"><Target :size="17" :stroke-width="2" /></span>
|
|
{{ i18n.t('app') }}
|
|
</router-link>
|
|
<div class="tabs">
|
|
<router-link v-if="auth.isAdmin" to="/" class="tab" :class="{ active: $route.path === '/' }">
|
|
{{ i18n.t('adminOverview') }}
|
|
</router-link>
|
|
<router-link to="/my/board" class="tab" :class="{ active: $route.path === '/my/board' }">
|
|
{{ i18n.t('myDashboard') }}
|
|
</router-link>
|
|
<router-link to="/training" class="tab" :class="{ active: $route.path === '/training' }">
|
|
{{ i18n.t('training') }}
|
|
</router-link>
|
|
</div>
|
|
<div class="nav-right">
|
|
<button @click="toggleLang" class="lang">{{ i18n.locale === 'th' ? 'EN' : 'TH' }}</button>
|
|
<div class="user-menu" ref="menuRef">
|
|
<button @click="menuOpen = !menuOpen" class="user-btn" :class="{ open: menuOpen }">
|
|
<UserCircle :size="20" :stroke-width="1.8" />
|
|
<span class="txt">{{ auth.user.username || auth.user.email || auth.user.name || auth.user.id }}</span>
|
|
<ChevronDown :size="14" :stroke-width="2" class="chev" :class="{ up: menuOpen }" />
|
|
</button>
|
|
<div v-if="menuOpen" class="dropdown">
|
|
<router-link to="/settings" class="item" @click="menuOpen = false">
|
|
<SettingsIcon :size="16" :stroke-width="1.8" /> {{ i18n.t('settings') }}
|
|
</router-link>
|
|
<button class="item" @click="logout"><LogOut :size="16" :stroke-width="1.8" /> {{ i18n.t('logout') }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
<main class="main">
|
|
<!-- :key forces each view to re-evaluate i18n.t() when locale changes -->
|
|
<router-view :key="i18n.locale" />
|
|
</main>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { onMounted, onBeforeUnmount, ref } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { Settings as SettingsIcon, LogOut, UserCircle, ChevronDown, Target } from 'lucide-vue-next'
|
|
import { auth } from './store/auth'
|
|
import { i18n } from './i18n'
|
|
|
|
const router = useRouter()
|
|
const menuOpen = ref(false)
|
|
const menuRef = ref(null)
|
|
|
|
function toggleLang() {
|
|
i18n.set(i18n.locale === 'th' ? 'en' : 'th')
|
|
}
|
|
function logout() {
|
|
menuOpen.value = false
|
|
auth.logout()
|
|
router.push('/login')
|
|
}
|
|
function onDocClick(e) {
|
|
if (menuRef.value && !menuRef.value.contains(e.target)) menuOpen.value = false
|
|
}
|
|
onMounted(() => {
|
|
document.addEventListener('click', onDocClick)
|
|
if (auth.token && !auth.user) auth.load()
|
|
})
|
|
onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
|
|
</script>
|
|
|
|
<style scoped>
|
|
.topnav {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
padding: 10px 24px;
|
|
background: rgba(255,255,255,.86);
|
|
backdrop-filter: saturate(180%) blur(12px);
|
|
border-bottom: 1px solid var(--border);
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 10;
|
|
flex-wrap: wrap;
|
|
}
|
|
.brand {
|
|
font-weight: 800; text-decoration: none; color: var(--ink); white-space: nowrap;
|
|
display: inline-flex; align-items: center; gap: 9px;
|
|
font-size: 15px; letter-spacing: -0.01em;
|
|
}
|
|
.brand-mark {
|
|
width: 30px; height: 30px; border-radius: 9px;
|
|
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
color: #fff; box-shadow: var(--shadow-accent); flex-shrink: 0;
|
|
}
|
|
.tabs { display: flex; gap: 4px; flex-wrap: wrap; background: var(--paper-2); padding: 4px; border-radius: 12px; border: 1px solid var(--border); }
|
|
.tab {
|
|
text-decoration: none;
|
|
color: var(--muted);
|
|
padding: 8px 16px;
|
|
border-radius: 9px;
|
|
font-weight: 650;
|
|
font-size: 14px;
|
|
white-space: nowrap;
|
|
transition: color .15s var(--ease), background .15s var(--ease), box-shadow .15s var(--ease);
|
|
}
|
|
.tab:hover { background: var(--card); color: var(--ink); }
|
|
.tab.active { background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #fff; box-shadow: var(--shadow-accent); }
|
|
.nav-right { display: flex; align-items: center; gap: 8px; }
|
|
.lang { padding: 8px 12px; font-weight: 700; }
|
|
.user-menu { position: relative; }
|
|
.user-btn {
|
|
display: inline-flex; align-items: center; gap: 7px;
|
|
padding: 8px 12px; border-radius: 10px; font-weight: 650; color: var(--ink);
|
|
border: 1px solid transparent;
|
|
}
|
|
.user-btn:hover, .user-btn.open { background: var(--paper-2); border-color: var(--border); }
|
|
.user-btn svg { color: var(--muted); }
|
|
.chev { transition: transform .15s var(--ease); color: var(--muted); }
|
|
.chev.up { transform: rotate(180deg); }
|
|
.dropdown {
|
|
position: absolute; right: 0; top: calc(100% + 8px); z-index: 30;
|
|
background: #fff; border: 1px solid var(--border); border-radius: 12px;
|
|
box-shadow: var(--shadow-md); min-width: 200px; overflow: hidden;
|
|
animation: drop-in .16s var(--ease);
|
|
}
|
|
@keyframes drop-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
|
|
.dropdown .item {
|
|
display: flex; align-items: center; gap: 9px; width: 100%;
|
|
padding: 11px 14px; border-radius: 0; background: transparent; color: var(--ink);
|
|
text-align: left; font-size: 14px; font-weight: 600; min-height: 44px;
|
|
}
|
|
.dropdown .item:hover { background: var(--paper-2); color: var(--accent); }
|
|
.dropdown .item + .item { border-top: 1px solid var(--border); }
|
|
.dropdown a.item { text-decoration: none; }
|
|
.dropdown .item svg { color: var(--muted); }
|
|
.dropdown .item:hover svg { color: var(--accent); }
|
|
.icon-btn { text-decoration: none; display: inline-flex; align-items: center; padding: 6px 10px; border-radius: 8px; color: var(--ink); }
|
|
.icon-btn:hover { background: #f1f3f9; }
|
|
.main { max-width: 1120px; margin: 0 auto; padding: 28px 24px; }
|
|
@media (max-width: 640px) {
|
|
.topnav { padding: 10px 12px; gap: 8px; }
|
|
.brand { font-size: 14px; }
|
|
.tabs { order: 3; width: 100%; justify-content: center; margin-top: 2px; }
|
|
.tab { padding: 8px 10px; font-size: 12px; flex: 1; text-align: center; }
|
|
.nav-right { margin-left: auto; }
|
|
.lang { padding: 6px 8px; font-size: 12px; }
|
|
.user-btn { padding: 6px 8px; }
|
|
.user-btn .txt { max-width: 88px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.main { padding: 18px 14px; }
|
|
}
|
|
@media (max-width: 380px) {
|
|
.brand { display: none; }
|
|
.brand-mark { display: none; }
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.dropdown, .card.lift, .bar-fill { animation: none !important; transition: none !important; }
|
|
}
|
|
</style>
|