Added pages for frontend
This commit is contained in:
parent
a10632e4bf
commit
a5f039d021
21 changed files with 2268 additions and 916 deletions
|
|
@ -4,7 +4,8 @@
|
|||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build"
|
||||
"build": "vite build",
|
||||
"format": "prettier --write --tab-width 4 --use-tabs false \"src/**/*.{ts,vue,js,css}\""
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
|
|
|
|||
346
src/app/App.vue
346
src/app/App.vue
|
|
@ -3,17 +3,17 @@ import { computed, onMounted } from 'vue';
|
|||
import { Layout, Menu, Button, Space, Typography } from 'ant-design-vue';
|
||||
import type { MenuProps } from 'ant-design-vue';
|
||||
import {
|
||||
HomeOutlined,
|
||||
InfoCircleOutlined,
|
||||
RocketOutlined,
|
||||
ReadOutlined,
|
||||
TeamOutlined,
|
||||
RobotOutlined,
|
||||
BulbOutlined,
|
||||
AppstoreOutlined,
|
||||
DashboardOutlined,
|
||||
LoginOutlined,
|
||||
UserAddOutlined,
|
||||
HomeOutlined,
|
||||
InfoCircleOutlined,
|
||||
RocketOutlined,
|
||||
ReadOutlined,
|
||||
TeamOutlined,
|
||||
RobotOutlined,
|
||||
BulbOutlined,
|
||||
AppstoreOutlined,
|
||||
DashboardOutlined,
|
||||
LoginOutlined,
|
||||
UserAddOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
|
@ -23,193 +23,193 @@ const route = useRoute();
|
|||
const authStore = useAuthStore();
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
key: '/',
|
||||
label: 'Home',
|
||||
icon: HomeOutlined,
|
||||
path: '/',
|
||||
},
|
||||
{
|
||||
key: '/about',
|
||||
label: 'About',
|
||||
icon: InfoCircleOutlined,
|
||||
path: '/about',
|
||||
},
|
||||
{
|
||||
key: '/onboarding',
|
||||
label: 'Onboarding',
|
||||
icon: RocketOutlined,
|
||||
path: '/onboarding',
|
||||
},
|
||||
{
|
||||
key: '/training',
|
||||
label: 'Training',
|
||||
icon: ReadOutlined,
|
||||
path: '/training',
|
||||
},
|
||||
{
|
||||
key: '/roles',
|
||||
label: 'Roles',
|
||||
icon: TeamOutlined,
|
||||
path: '/roles',
|
||||
roles: ['manager', 'admin'],
|
||||
},
|
||||
{
|
||||
key: '/agents',
|
||||
label: 'Agents',
|
||||
icon: RobotOutlined,
|
||||
path: '/agents',
|
||||
roles: ['manager', 'admin'],
|
||||
},
|
||||
{
|
||||
key: '/assessments',
|
||||
label: 'Assessments',
|
||||
icon: BulbOutlined,
|
||||
path: '/assessments',
|
||||
},
|
||||
{
|
||||
key: '/resources',
|
||||
label: 'Resources',
|
||||
icon: AppstoreOutlined,
|
||||
path: '/resources',
|
||||
},
|
||||
{
|
||||
key: '/progress',
|
||||
label: 'Progress',
|
||||
icon: DashboardOutlined,
|
||||
path: '/progress',
|
||||
},
|
||||
{
|
||||
key: '/',
|
||||
label: 'Home',
|
||||
icon: HomeOutlined,
|
||||
path: '/',
|
||||
},
|
||||
{
|
||||
key: '/about',
|
||||
label: 'About',
|
||||
icon: InfoCircleOutlined,
|
||||
path: '/about',
|
||||
},
|
||||
{
|
||||
key: '/onboarding',
|
||||
label: 'Onboarding',
|
||||
icon: RocketOutlined,
|
||||
path: '/onboarding',
|
||||
},
|
||||
{
|
||||
key: '/training',
|
||||
label: 'Training',
|
||||
icon: ReadOutlined,
|
||||
path: '/training',
|
||||
},
|
||||
{
|
||||
key: '/roles',
|
||||
label: 'Roles',
|
||||
icon: TeamOutlined,
|
||||
path: '/roles',
|
||||
roles: ['manager', 'admin'],
|
||||
},
|
||||
{
|
||||
key: '/agents',
|
||||
label: 'Agents',
|
||||
icon: RobotOutlined,
|
||||
path: '/agents',
|
||||
roles: ['manager', 'admin'],
|
||||
},
|
||||
{
|
||||
key: '/assessments',
|
||||
label: 'Assessments',
|
||||
icon: BulbOutlined,
|
||||
path: '/assessments',
|
||||
},
|
||||
{
|
||||
key: '/resources',
|
||||
label: 'Resources',
|
||||
icon: AppstoreOutlined,
|
||||
path: '/resources',
|
||||
},
|
||||
{
|
||||
key: '/progress',
|
||||
label: 'Progress',
|
||||
icon: DashboardOutlined,
|
||||
path: '/progress',
|
||||
},
|
||||
];
|
||||
|
||||
const visibleNavItems = computed(() =>
|
||||
navItems.filter((item) =>
|
||||
item.roles ? authStore.hasRole(item.roles) : true
|
||||
)
|
||||
navItems.filter((item) =>
|
||||
item.roles ? authStore.hasRole(item.roles) : true
|
||||
)
|
||||
);
|
||||
|
||||
const selectedKeys = computed(() => {
|
||||
const match = visibleNavItems.value.find((item) =>
|
||||
route.path.startsWith(item.key)
|
||||
);
|
||||
return match ? [match.key] : [];
|
||||
const match = visibleNavItems.value.find((item) =>
|
||||
route.path.startsWith(item.key)
|
||||
);
|
||||
return match ? [match.key] : [];
|
||||
});
|
||||
|
||||
const onSelect: MenuProps['onSelect'] = ({ key }) => {
|
||||
const item = visibleNavItems.value.find((n) => n.key === key);
|
||||
if (item) router.push(item.path);
|
||||
const item = visibleNavItems.value.find((n) => n.key === key);
|
||||
if (item) router.push(item.path);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await authStore.logout();
|
||||
router.push('/');
|
||||
await authStore.logout();
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
authStore.fetchSession();
|
||||
authStore.fetchSession();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout class="shell">
|
||||
<Layout.Header class="shell-header">
|
||||
<div class="brand" @click="router.push('/')">Dynavera</div>
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
theme="dark"
|
||||
:selectedKeys="selectedKeys"
|
||||
class="shell-menu"
|
||||
@select="onSelect"
|
||||
>
|
||||
<Menu.Item v-for="item in visibleNavItems" :key="item.key">
|
||||
<Space size="small">
|
||||
<component :is="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</Space>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
<Space>
|
||||
<template v-if="authStore.isAuthenticated">
|
||||
<Typography.Text class="user-chip" strong>
|
||||
{{ authStore.displayName || 'Account' }}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
ghost
|
||||
:loading="authStore.loading"
|
||||
@click="handleLogout"
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button ghost @click="router.push('/login')">
|
||||
<LoginOutlined /> Login
|
||||
</Button>
|
||||
<Button type="primary" @click="router.push('/register')">
|
||||
<UserAddOutlined /> Register
|
||||
</Button>
|
||||
</template>
|
||||
</Space>
|
||||
</Layout.Header>
|
||||
<Layout class="shell">
|
||||
<Layout.Header class="shell-header">
|
||||
<div class="brand" @click="router.push('/')">Dynavera</div>
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
theme="dark"
|
||||
:selectedKeys="selectedKeys"
|
||||
class="shell-menu"
|
||||
@select="onSelect"
|
||||
>
|
||||
<Menu.Item v-for="item in visibleNavItems" :key="item.key">
|
||||
<Space size="small">
|
||||
<component :is="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</Space>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
<Space>
|
||||
<template v-if="authStore.isAuthenticated">
|
||||
<Typography.Text class="user-chip" strong>
|
||||
{{ authStore.displayName || 'Account' }}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
ghost
|
||||
:loading="authStore.loading"
|
||||
@click="handleLogout"
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button ghost @click="router.push('/login')">
|
||||
<LoginOutlined /> Login
|
||||
</Button>
|
||||
<Button type="primary" @click="router.push('/register')">
|
||||
<UserAddOutlined /> Register
|
||||
</Button>
|
||||
</template>
|
||||
</Space>
|
||||
</Layout.Header>
|
||||
|
||||
<Layout class="shell-body">
|
||||
<Layout.Content class="shell-content">
|
||||
<router-view />
|
||||
</Layout.Content>
|
||||
<Layout.Footer class="shell-footer">
|
||||
<Typography.Text type="secondary">
|
||||
<strong>Project Disclaimer:</strong> This is a
|
||||
proof-of-concept demo project for educational purposes. All
|
||||
testimonials, statistics, and company names are fictional
|
||||
placeholders.
|
||||
</Typography.Text>
|
||||
</Layout.Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
<Layout class="shell-body">
|
||||
<Layout.Content class="shell-content">
|
||||
<router-view />
|
||||
</Layout.Content>
|
||||
<Layout.Footer class="shell-footer">
|
||||
<Typography.Text type="secondary">
|
||||
<strong>Project Disclaimer:</strong> This is a
|
||||
proof-of-concept demo project for educational purposes. All
|
||||
testimonials, statistics, and company names are fictional
|
||||
placeholders.
|
||||
</Typography.Text>
|
||||
</Layout.Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
background: #0b1220;
|
||||
min-height: 100vh;
|
||||
background: #0b1220;
|
||||
}
|
||||
.shell-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0 1.25rem;
|
||||
background: #0f172a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0 1.25rem;
|
||||
background: #0f172a;
|
||||
}
|
||||
.brand {
|
||||
color: #e5e7eb;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-size: 1.05rem;
|
||||
color: #e5e7eb;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.shell-menu {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border-bottom: none;
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border-bottom: none;
|
||||
}
|
||||
.shell-body {
|
||||
background: #0b1220;
|
||||
min-height: calc(100vh - 64px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #0b1220;
|
||||
min-height: calc(100vh - 64px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.shell-content {
|
||||
padding: 24px;
|
||||
flex: 1;
|
||||
min-height: calc(100vh - 64px - 64px);
|
||||
padding: 24px;
|
||||
flex: 1;
|
||||
min-height: calc(100vh - 64px - 64px);
|
||||
}
|
||||
.shell-footer {
|
||||
text-align: center;
|
||||
background: #0f172a;
|
||||
text-align: center;
|
||||
background: #0f172a;
|
||||
}
|
||||
:deep(.ant-menu-dark) {
|
||||
background: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
:deep(.ant-menu-dark .ant-menu-item-selected) {
|
||||
background: transparent !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
:deep(.ant-typography),
|
||||
:deep(.ant-typography p),
|
||||
|
|
@ -221,41 +221,41 @@ onMounted(() => {
|
|||
:deep(.ant-statistic-content),
|
||||
:deep(.ant-card-meta-title),
|
||||
:deep(.ant-card-meta-description) {
|
||||
color: #e5e7eb;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
:deep(.ant-typography-secondary) {
|
||||
color: #cbd5e1 !important;
|
||||
color: #cbd5e1 !important;
|
||||
}
|
||||
:deep(.ant-form-item-label > label) {
|
||||
color: #e5e7eb;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
:deep(.ant-input),
|
||||
:deep(.ant-select-selector),
|
||||
:deep(.ant-select-selection-item),
|
||||
:deep(.ant-picker-input input) {
|
||||
background: #111827;
|
||||
color: #e5e7eb;
|
||||
border-color: #334155;
|
||||
background: #111827;
|
||||
color: #e5e7eb;
|
||||
border-color: #334155;
|
||||
}
|
||||
:deep(.ant-input::placeholder),
|
||||
:deep(.ant-select-selection-placeholder),
|
||||
:deep(.ant-picker-input input::placeholder) {
|
||||
color: #9ca3af;
|
||||
color: #9ca3af;
|
||||
}
|
||||
:deep(.ant-card) {
|
||||
background: #0f172a;
|
||||
border-color: #1f2937;
|
||||
background: #0f172a;
|
||||
border-color: #1f2937;
|
||||
}
|
||||
:deep(.ant-btn:not(.ant-btn-primary)) {
|
||||
color: #e5e7eb;
|
||||
border-color: #334155;
|
||||
background: #111827;
|
||||
color: #e5e7eb;
|
||||
border-color: #334155;
|
||||
background: #111827;
|
||||
}
|
||||
:deep(.ant-btn-primary) {
|
||||
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
||||
border: none;
|
||||
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
||||
border: none;
|
||||
}
|
||||
.user-chip {
|
||||
color: #e5e7eb;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
67
src/lib/api.ts
Normal file
67
src/lib/api.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({ withCredentials: true });
|
||||
}
|
||||
|
||||
private getCsrfToken(): string {
|
||||
const match = document.cookie.match(/(?:^|; )csrftoken=([^;]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : '';
|
||||
}
|
||||
|
||||
private withCsrf(config?: AxiosRequestConfig): AxiosRequestConfig {
|
||||
const token = this.getCsrfToken();
|
||||
const csrfHeader = token ? { 'X-CSRFToken': token } : {};
|
||||
return {
|
||||
...config,
|
||||
headers: {
|
||||
...csrfHeader,
|
||||
...(config?.headers || {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
get<T = unknown>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.client.get<T>(url, this.withCsrf(config));
|
||||
}
|
||||
|
||||
post<T = unknown>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.client.post<T>(url, data, this.withCsrf(config));
|
||||
}
|
||||
|
||||
put<T = unknown>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.client.put<T>(url, data, this.withCsrf(config));
|
||||
}
|
||||
|
||||
patch<T = unknown>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.client.patch<T>(url, data, this.withCsrf(config));
|
||||
}
|
||||
|
||||
delete<T = unknown>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.client.delete<T>(url, this.withCsrf(config));
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
export { isAxiosError } from 'axios';
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
import './styles.css';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
import router from './router';
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import App from './app/App.vue';
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.mount('#root');
|
||||
|
|
|
|||
|
|
@ -1,69 +1,110 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('../views/HomeView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
component: () => import('../views/AboutView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: () => import('../views/RegisterView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/onboarding',
|
||||
name: 'onboarding',
|
||||
component: () => import('../views/OnboardingFlow.vue'),
|
||||
},
|
||||
{
|
||||
path: '/training/:moduleId?',
|
||||
name: 'training',
|
||||
component: () => import('../views/TrainingModule.vue'),
|
||||
},
|
||||
{
|
||||
path: '/agents',
|
||||
name: 'agents',
|
||||
component: () => import('../views/Agents.vue'),
|
||||
},
|
||||
{
|
||||
path: '/agents/:id',
|
||||
name: 'agent-detail',
|
||||
component: () => import('../views/AgentDetail.vue'),
|
||||
},
|
||||
{
|
||||
path: '/roles',
|
||||
name: 'roles',
|
||||
component: () => import('../views/RoleProfiles.vue'),
|
||||
},
|
||||
{
|
||||
path: '/progress',
|
||||
name: 'progress',
|
||||
component: () => import('../views/ProgressDashboard.vue'),
|
||||
},
|
||||
{
|
||||
path: '/assessments',
|
||||
name: 'assessments',
|
||||
component: () => import('../views/Assessments.vue'),
|
||||
},
|
||||
{
|
||||
path: '/resources',
|
||||
name: 'resources',
|
||||
component: () => import('../views/Resources.vue'),
|
||||
},
|
||||
],
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('../views/HomeView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
component: () => import('../views/AboutView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
meta: { guestOnly: true },
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: () => import('../views/RegisterView.vue'),
|
||||
meta: { guestOnly: true },
|
||||
},
|
||||
{
|
||||
path: '/onboarding',
|
||||
name: 'onboarding',
|
||||
component: () => import('../views/OnboardingFlow.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/training/:moduleId?',
|
||||
name: 'training',
|
||||
component: () => import('../views/TrainingModule.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/agents',
|
||||
name: 'agents',
|
||||
component: () => import('../views/Agents.vue'),
|
||||
meta: { requiresAuth: true, roles: ['manager', 'admin'] },
|
||||
},
|
||||
{
|
||||
path: '/agents/:id',
|
||||
name: 'agent-detail',
|
||||
component: () => import('../views/AgentDetail.vue'),
|
||||
meta: { requiresAuth: true, roles: ['manager', 'admin'] },
|
||||
},
|
||||
{
|
||||
path: '/roles',
|
||||
name: 'roles',
|
||||
component: () => import('../views/RoleProfiles.vue'),
|
||||
meta: { requiresAuth: true, roles: ['manager', 'admin'] },
|
||||
},
|
||||
{
|
||||
path: '/progress',
|
||||
name: 'progress',
|
||||
component: () => import('../views/ProgressDashboard.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/assessments',
|
||||
name: 'assessments',
|
||||
component: () => import('../views/Assessments.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/resources',
|
||||
name: 'resources',
|
||||
component: () => import('../views/Resources.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
try {
|
||||
await authStore.fetchSession();
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch session during navigation:', err);
|
||||
}
|
||||
|
||||
const isAuthed = authStore.isAuthenticated;
|
||||
const role = authStore.user?.role;
|
||||
|
||||
if (to.meta?.guestOnly && isAuthed) {
|
||||
return next({ path: '/onboarding' });
|
||||
}
|
||||
|
||||
if (to.meta?.requiresAuth && !isAuthed) {
|
||||
return next({ path: '/login', query: { redirect: to.fullPath } });
|
||||
}
|
||||
|
||||
const allowedRoles = (to.meta?.roles as string[] | undefined) || null;
|
||||
if (allowedRoles && (!role || !allowedRoles.includes(role))) {
|
||||
message.error('You do not have access to that page');
|
||||
return next({ path: '/' });
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
|
|
|
|||
271
src/stores/agentStore.ts
Normal file
271
src/stores/agentStore.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
interface AgentEvent {
|
||||
type: string;
|
||||
content?: string | Record<string, unknown>;
|
||||
message?: string;
|
||||
timestamp: Date;
|
||||
event_type?: string;
|
||||
error_message?: string;
|
||||
execution_id?: string;
|
||||
output_data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const useAgentStore = defineStore('agent', () => {
|
||||
const socket = ref<WebSocket | null>(null);
|
||||
const isConnected = ref(false);
|
||||
const currentExecutionId = ref<string | null>(null);
|
||||
const events = ref<AgentEvent[]>([]);
|
||||
const executionStatus = ref<string>('idle');
|
||||
const agentId = ref<string | null>(null);
|
||||
const reconnectAttempts = ref(0);
|
||||
const maxReconnectAttempts = 5;
|
||||
|
||||
const connect = (agentIdParam: string) => {
|
||||
console.log(
|
||||
'[agentStore] connect() called with agent ID:',
|
||||
agentIdParam
|
||||
);
|
||||
|
||||
if (socket.value && isConnected.value) {
|
||||
console.log(
|
||||
'[agentStore] Already connected to agent:',
|
||||
agentIdParam
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
agentId.value = agentIdParam;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/agents/${agentIdParam}/`;
|
||||
console.log('[agentStore] WebSocket URL:', wsUrl);
|
||||
|
||||
socket.value = new WebSocket(wsUrl);
|
||||
console.log('[agentStore] WebSocket object created');
|
||||
|
||||
socket.value.onopen = () => {
|
||||
isConnected.value = true;
|
||||
reconnectAttempts.value = 0;
|
||||
console.log(
|
||||
'[agentStore] SUCCESS - WebSocket connected to agent:',
|
||||
agentIdParam
|
||||
);
|
||||
console.log('[agentStore] isConnected is now:', isConnected.value);
|
||||
};
|
||||
|
||||
socket.value.onmessage = (event) => {
|
||||
console.log('[agentStore] Message received from WebSocket');
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('[agentStore] Parsed message:', data);
|
||||
handleMessage(data);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[agentStore] ERROR - Failed to parse WebSocket message:',
|
||||
error
|
||||
);
|
||||
console.error('[agentStore] Raw message:', event.data);
|
||||
}
|
||||
};
|
||||
|
||||
socket.value.onerror = (error) => {
|
||||
console.error(
|
||||
'[agentStore] ERROR - WebSocket error occurred:',
|
||||
error
|
||||
);
|
||||
isConnected.value = false;
|
||||
};
|
||||
|
||||
socket.value.onclose = () => {
|
||||
isConnected.value = false;
|
||||
console.log(
|
||||
'[agentStore] WebSocket closed for agent:',
|
||||
agentIdParam
|
||||
);
|
||||
attemptReconnect(agentIdParam);
|
||||
};
|
||||
};
|
||||
|
||||
const attemptReconnect = (agentIdParam: string) => {
|
||||
console.log('[agentStore] attemptReconnect() called');
|
||||
|
||||
if (reconnectAttempts.value < maxReconnectAttempts) {
|
||||
reconnectAttempts.value++;
|
||||
const delay = Math.min(
|
||||
1000 * Math.pow(2, reconnectAttempts.value),
|
||||
10000
|
||||
);
|
||||
console.log(
|
||||
`[agentStore] Attempting to reconnect... (attempt ${reconnectAttempts.value}/${maxReconnectAttempts}, delay: ${delay}ms)`
|
||||
);
|
||||
setTimeout(() => connect(agentIdParam), delay);
|
||||
} else {
|
||||
console.error(
|
||||
'[agentStore] ERROR - Max reconnection attempts reached (${maxReconnectAttempts})'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessage = (data: Record<string, unknown>) => {
|
||||
console.log(
|
||||
'[agentStore] handleMessage() called with type:',
|
||||
data.type
|
||||
);
|
||||
console.log('[agentStore] Full message data:', data);
|
||||
|
||||
if (data.type === 'connection') {
|
||||
console.log('[agentStore] Connection message:', data.message);
|
||||
} else if (data.type === 'execution_started') {
|
||||
console.log('[agentStore] Execution started');
|
||||
currentExecutionId.value = data.execution_id as string;
|
||||
executionStatus.value = 'running';
|
||||
events.value = [];
|
||||
console.log(
|
||||
'[agentStore] Status changed to: running, execution ID:',
|
||||
currentExecutionId.value
|
||||
);
|
||||
events.value.push({
|
||||
type: 'started',
|
||||
message: data.message as string,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
} else if (data.type === 'agent_event') {
|
||||
console.log('[agentStore] Agent event received:', data.event_type);
|
||||
events.value.push({
|
||||
type: data.event_type as string,
|
||||
content: data.content as string | Record<string, unknown>,
|
||||
timestamp: new Date(data.timestamp as string),
|
||||
});
|
||||
} else if (data.type === 'execution_completed') {
|
||||
console.log('[agentStore] Execution completed');
|
||||
executionStatus.value = 'completed';
|
||||
events.value.push({
|
||||
type: 'completed',
|
||||
content: data.output_data as Record<string, unknown>,
|
||||
message: data.message as string,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
} else if (data.type === 'execution_error') {
|
||||
console.log('[agentStore] Execution error:', data.error_message);
|
||||
executionStatus.value = 'failed';
|
||||
events.value.push({
|
||||
type: 'error',
|
||||
message: data.error_message as string,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
} else if (data.type === 'execution_stopped') {
|
||||
console.log('[agentStore] Execution stopped');
|
||||
executionStatus.value = 'stopped';
|
||||
events.value.push({
|
||||
type: 'stopped',
|
||||
message: data.message as string,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
} else if (data.type === 'error') {
|
||||
console.log('[agentStore] Generic error:', data.message);
|
||||
events.value.push({
|
||||
type: 'error',
|
||||
message: data.message as string,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
'[agentStore] WARNING - Unknown message type:',
|
||||
data.type
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const startAgent = (inputData: Record<string, unknown> = {}) => {
|
||||
console.log('[agentStore] startAgent() called with data:', inputData);
|
||||
|
||||
if (!socket.value) {
|
||||
console.error('[agentStore] ERROR - WebSocket not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isConnected.value) {
|
||||
console.error(
|
||||
'[agentStore] ERROR - WebSocket not connected (isConnected:',
|
||||
isConnected.value,
|
||||
')'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const message = {
|
||||
action: 'start_agent',
|
||||
input_data: inputData,
|
||||
};
|
||||
console.log('[agentStore] Sending message:', message);
|
||||
socket.value.send(JSON.stringify(message));
|
||||
console.log('[agentStore] SUCCESS - Message sent to WebSocket');
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[agentStore] ERROR - Failed to send WebSocket message:',
|
||||
error
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const stopAgent = () => {
|
||||
console.log('[agentStore] stopAgent() called');
|
||||
|
||||
if (!socket.value) {
|
||||
console.error('[agentStore] ERROR - WebSocket not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isConnected.value) {
|
||||
console.error('[agentStore] ERROR - WebSocket not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const message = {
|
||||
action: 'stop_agent',
|
||||
execution_id: currentExecutionId.value,
|
||||
};
|
||||
console.log('[agentStore] Sending message:', message);
|
||||
socket.value.send(JSON.stringify(message));
|
||||
console.log(
|
||||
'[agentStore] SUCCESS - Stop message sent to WebSocket'
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[agentStore] ERROR - Failed to send stop message:',
|
||||
error
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
console.log('[agentStore] disconnect() called');
|
||||
reconnectAttempts.value = maxReconnectAttempts;
|
||||
|
||||
if (socket.value) {
|
||||
console.log('[agentStore] Closing WebSocket connection');
|
||||
socket.value.close();
|
||||
console.log('[agentStore] WebSocket close initiated');
|
||||
} else {
|
||||
console.warn('[agentStore] WARNING - No WebSocket to disconnect');
|
||||
}
|
||||
};
|
||||
|
||||
const eventLog = computed(() => events.value);
|
||||
|
||||
return {
|
||||
socket,
|
||||
isConnected,
|
||||
currentExecutionId,
|
||||
executionStatus,
|
||||
agentId,
|
||||
eventLog,
|
||||
connect,
|
||||
startAgent,
|
||||
stopAgent,
|
||||
disconnect,
|
||||
};
|
||||
});
|
||||
159
src/stores/authStore.ts
Normal file
159
src/stores/authStore.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { computed, ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { apiClient, isAxiosError } from '../lib/api';
|
||||
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
uuid: string;
|
||||
email_address: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
bio?: string;
|
||||
timezone?: string;
|
||||
avatar_url?: string;
|
||||
role?: string;
|
||||
date_of_birth?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface SessionResponse {
|
||||
isAuthenticated: boolean;
|
||||
isStaff: boolean;
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<AuthUser | null>(null);
|
||||
const loading = ref(false);
|
||||
const initialized = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const isAuthenticated = computed(() => Boolean(user.value));
|
||||
const hasRole = (roles: string[] = []) => {
|
||||
if (!roles.length) return true;
|
||||
return roles.includes(user.value?.role || '');
|
||||
};
|
||||
const displayName = computed(() => {
|
||||
if (!user.value) return '';
|
||||
if (user.value.first_name || user.value.last_name) {
|
||||
return `${user.value.first_name || ''} ${
|
||||
user.value.last_name || ''
|
||||
}`.trim();
|
||||
}
|
||||
return user.value.email_address;
|
||||
});
|
||||
|
||||
const setUser = (value: AuthUser | null) => {
|
||||
user.value = value;
|
||||
initialized.value = true;
|
||||
};
|
||||
|
||||
const fetchSession = async (force = false) => {
|
||||
if (initialized.value && !force) return user.value;
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const sessionRes = await apiClient.get<SessionResponse>(
|
||||
'/api/user/session/'
|
||||
);
|
||||
if (sessionRes.data?.isAuthenticated) {
|
||||
const meRes = await apiClient.get<AuthUser>('/api/user/me/');
|
||||
setUser(meRes.data);
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
return user.value;
|
||||
} catch (err) {
|
||||
error.value = isAxiosError(err)
|
||||
? err.response?.data?.detail || err.message
|
||||
: 'Unable to fetch session';
|
||||
setUser(null);
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (emailAddress: string, password: string) => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const res = await apiClient.post<{
|
||||
user: AuthUser;
|
||||
message?: string;
|
||||
}>('/api/user/login/', { email_address: emailAddress, password });
|
||||
setUser(res.data?.user ?? null);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
error.value = isAxiosError(err)
|
||||
? err.response?.data?.error ||
|
||||
err.response?.data?.detail ||
|
||||
err.message
|
||||
: 'Login failed';
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (payload: {
|
||||
email_address: string;
|
||||
password: string;
|
||||
confirm_password?: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
date_of_birth?: string;
|
||||
role?: string;
|
||||
}) => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await apiClient.post('/api/user/signup/', {
|
||||
...payload,
|
||||
confirm_password: payload.confirm_password || payload.password,
|
||||
});
|
||||
await login(payload.email_address, payload.password);
|
||||
} catch (err) {
|
||||
error.value = isAxiosError(err)
|
||||
? err.response?.data?.detail ||
|
||||
err.response?.data?.error ||
|
||||
err.message
|
||||
: 'Registration failed';
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await apiClient.post('/api/user/logout/');
|
||||
} catch (err) {
|
||||
error.value = isAxiosError(err)
|
||||
? err.response?.data?.detail ||
|
||||
err.response?.data?.error ||
|
||||
err.message
|
||||
: 'Logout failed';
|
||||
throw err;
|
||||
} finally {
|
||||
setUser(null);
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
loading,
|
||||
initialized,
|
||||
error,
|
||||
isAuthenticated,
|
||||
hasRole,
|
||||
displayName,
|
||||
fetchSession,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
};
|
||||
});
|
||||
|
|
@ -1,41 +1,44 @@
|
|||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
line-height: 1.5;
|
||||
tab-size: 4;
|
||||
scroll-behavior: smooth;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
line-height: 1.5;
|
||||
tab-size: 4;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
body {
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
margin: 0;
|
||||
background: #0b1220;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
p,
|
||||
pre {
|
||||
margin: 0;
|
||||
margin: 0;
|
||||
}
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-color: currentColor;
|
||||
box-sizing: border-box;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-color: currentColor;
|
||||
}
|
||||
h1,
|
||||
h2 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: inherit;
|
||||
color: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
pre {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
'Liberation Mono', 'Courier New', monospace;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
'Liberation Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,63 +1,134 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
Card,
|
||||
Typography,
|
||||
Divider,
|
||||
List,
|
||||
Timeline,
|
||||
Space,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
const pathways = [
|
||||
'Admin: system settings, user management, reporting, invitations.',
|
||||
'Manager: create onboarding flows, assign roles, monitor team progress.',
|
||||
'Employee: complete training modules, assessments, and track personal progress.',
|
||||
];
|
||||
|
||||
const highlights = [
|
||||
'Ready for agent-driven workflows that guide people through onboarding tasks.',
|
||||
'Flexible role-based gating across pages (managers/admins vs employees).',
|
||||
'Django REST API + Vue 3 frontend with a shared Pinia auth/session store.',
|
||||
'Docker-friendly dev setup (frontend on 5173, API on 8000).',
|
||||
];
|
||||
|
||||
const roadmap = [
|
||||
{
|
||||
title: 'Short term',
|
||||
items: [
|
||||
'Add richer assessments with adaptive scoring.',
|
||||
'Improve content versioning for training modules.',
|
||||
'Expose activity feed for audits.',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Next',
|
||||
items: [
|
||||
'Integrate external IDP (SSO) and SCIM user sync.',
|
||||
'Launch webhooks for downstream HRIS updates.',
|
||||
'Add multilingual content support.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const steps = [
|
||||
'Register or login (demo credentials only).',
|
||||
'Complete Onboarding and Training to simulate a role journey.',
|
||||
'Managers assign employees to roles and review progress reports.',
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="about">
|
||||
<h1>About Agentic Trainers</h1>
|
||||
<p>
|
||||
Agentic Trainers is a lightweight platform for onboarding, training,
|
||||
and assessing employees using modular training content and
|
||||
agent-driven workflows. This repo contains a demo front-end and
|
||||
example pipelines for role-based experiences.
|
||||
</p>
|
||||
<div class="page">
|
||||
<Card class="panel" :bordered="false">
|
||||
<Typography.Title :level="2"
|
||||
>About Agentic Trainers</Typography.Title
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
Agentic Trainers is a lightweight platform for onboarding,
|
||||
training, and assessing employees with modular content and
|
||||
agent-driven workflows. It is designed for teams that want to
|
||||
ship tangible learning experiences quickly without complex LMS
|
||||
setup.
|
||||
</Typography.Paragraph>
|
||||
|
||||
<h2>Role pathways</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Admin</strong>: full access to system settings, user
|
||||
management, and reporting. Admins can invite or deactivate
|
||||
managers and view overall progress.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Manager</strong>: responsible for company-level tasks:
|
||||
create onboarding flows, assign employees to roles, and monitor
|
||||
team progress.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Employee</strong>: follows onboarding and training
|
||||
modules, completes assessments, and views personal progress on
|
||||
the dashboard.
|
||||
</li>
|
||||
</ul>
|
||||
<Divider />
|
||||
<Typography.Title :level="4">Role pathways</Typography.Title>
|
||||
<List :data-source="pathways" :bordered="false">
|
||||
<template #renderItem="{ item }">
|
||||
<List.Item class="row">{{ item }}</List.Item>
|
||||
</template>
|
||||
</List>
|
||||
|
||||
<h2>Getting started</h2>
|
||||
<ol>
|
||||
<li>
|
||||
Register or login from the top-right to choose your role (demo
|
||||
only).
|
||||
</li>
|
||||
<li>
|
||||
Use the <em>Onboarding</em> and <em>Training</em> links to
|
||||
begin.
|
||||
</li>
|
||||
<li>
|
||||
Managers can create roles and assign employees via the
|
||||
<em>Roles</em> page.
|
||||
</li>
|
||||
</ol>
|
||||
<Divider />
|
||||
<Typography.Title :level="4">Highlights</Typography.Title>
|
||||
<List :data-source="highlights" :bordered="false">
|
||||
<template #renderItem="{ item }">
|
||||
<List.Item class="row">{{ item }}</List.Item>
|
||||
</template>
|
||||
</List>
|
||||
|
||||
<p>
|
||||
This is a demo implementation — authentication and permissions are
|
||||
local-storage based for example purposes. For production-grade apps
|
||||
integrate a backend auth provider and persistent user store.
|
||||
</p>
|
||||
</div>
|
||||
<Divider />
|
||||
<Typography.Title :level="4">Getting started</Typography.Title>
|
||||
<List :data-source="steps" :bordered="false">
|
||||
<template #renderItem="{ item, index }">
|
||||
<List.Item class="row"
|
||||
><strong>{{ index + 1 }}.</strong> {{
|
||||
item
|
||||
}}</List.Item
|
||||
>
|
||||
</template>
|
||||
</List>
|
||||
|
||||
<Divider />
|
||||
<Typography.Title :level="4">Roadmap</Typography.Title>
|
||||
<Space :size="24" direction="vertical" style="width: 100%">
|
||||
<Timeline>
|
||||
<Timeline.Item
|
||||
v-for="bucket in roadmap"
|
||||
:key="bucket.title"
|
||||
>
|
||||
<Typography.Text strong>{{
|
||||
bucket.title
|
||||
}}</Typography.Text>
|
||||
<List :data-source="bucket.items" :bordered="false">
|
||||
<template #renderItem="{ item }">
|
||||
<List.Item class="row">{{ item }}</List.Item>
|
||||
</template>
|
||||
</List>
|
||||
</Timeline.Item>
|
||||
</Timeline>
|
||||
</Space>
|
||||
|
||||
<Typography.Paragraph type="secondary" style="margin-top: 1rem">
|
||||
Demo-only auth; integrate a real identity provider and
|
||||
persistence for production.
|
||||
</Typography.Paragraph>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@media (min-width: 768px) {
|
||||
.about {
|
||||
max-width: 768px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.panel {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.row {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,68 +1,421 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import {
|
||||
Card,
|
||||
Typography,
|
||||
Button,
|
||||
List,
|
||||
Space,
|
||||
Spin,
|
||||
Input,
|
||||
message,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
import { useAgentStore } from '../stores/agentStore';
|
||||
import { apiClient, isAxiosError } from '../lib/api';
|
||||
|
||||
const route = useRoute();
|
||||
const id = route.params.id || 'unknown';
|
||||
console.log('[AgentDetail] Route params:', route.params);
|
||||
|
||||
const agent = ref({
|
||||
id,
|
||||
name: `Agent ${id}`,
|
||||
description:
|
||||
'This agent helps new hires by providing step-by-step assistance and quick answers to common questions.',
|
||||
const agentStore = useAgentStore();
|
||||
console.log('[AgentDetail] Store instance:', agentStore);
|
||||
|
||||
const agentId = route.params.id as string;
|
||||
console.log('[AgentDetail] Agent ID:', agentId);
|
||||
|
||||
if (!agentId) {
|
||||
console.error('[AgentDetail] ERROR: No agent ID in route params');
|
||||
}
|
||||
|
||||
const agent = ref<Record<string, unknown>>({
|
||||
id: agentId,
|
||||
name: 'Loading...',
|
||||
description: '',
|
||||
status: 'idle',
|
||||
});
|
||||
|
||||
console.log('[AgentDetail] Initial agent state:', agent.value);
|
||||
|
||||
const queryInput = ref('');
|
||||
const isRunning = computed(() => {
|
||||
console.log(
|
||||
'[AgentDetail] isRunning computed - executionStatus:',
|
||||
agentStore.executionStatus
|
||||
);
|
||||
return agentStore.executionStatus === 'running';
|
||||
});
|
||||
const isConnected = computed(() => {
|
||||
console.log(
|
||||
'[AgentDetail] isConnected computed - isConnected:',
|
||||
agentStore.isConnected
|
||||
);
|
||||
return agentStore.isConnected ?? false;
|
||||
});
|
||||
|
||||
const agentResponse = computed(() => {
|
||||
const completedEvent = agentStore.eventLog?.find(
|
||||
(event) => event.type === 'completed'
|
||||
);
|
||||
if (completedEvent?.content && typeof completedEvent.content === 'object') {
|
||||
const output = completedEvent.content as Record<string, unknown>;
|
||||
return (output.response as string) || null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
idle: 'default',
|
||||
running: 'processing',
|
||||
completed: 'success',
|
||||
failed: 'error',
|
||||
stopped: 'warning',
|
||||
};
|
||||
return colors[status] || 'default';
|
||||
};
|
||||
|
||||
const fetchAgent = async () => {
|
||||
console.log('[AgentDetail] Fetching agent details for ID:', agentId);
|
||||
try {
|
||||
const response = await apiClient.get(`/api/agent/${agentId}/`);
|
||||
agent.value = response.data;
|
||||
console.log('[AgentDetail] Agent fetched successfully:', agent.value);
|
||||
} catch (error) {
|
||||
console.error('[AgentDetail] ERROR - Failed to fetch agent:', error);
|
||||
if (isAxiosError(error)) {
|
||||
console.error('[AgentDetail] Axios error details:', {
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
message.error('Failed to load agent details');
|
||||
}
|
||||
};
|
||||
|
||||
const startAgent = () => {
|
||||
console.log('[AgentDetail] Starting agent execution');
|
||||
|
||||
if (!agentStore.isConnected) {
|
||||
console.warn('[AgentDetail] WARNING: WebSocket not connected');
|
||||
console.log('[AgentDetail] Connection state:', {
|
||||
isConnected: agentStore.isConnected,
|
||||
});
|
||||
message.error('WebSocket not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!queryInput.value.trim()) {
|
||||
message.error('Please enter a query');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = {
|
||||
query: queryInput.value.trim(),
|
||||
};
|
||||
console.log('[AgentDetail] Sending data:', data);
|
||||
|
||||
console.log('[AgentDetail] Calling startAgent on store');
|
||||
agentStore.startAgent(data);
|
||||
console.log('[AgentDetail] Agent execution initiated');
|
||||
message.success('Agent execution started');
|
||||
} catch (error) {
|
||||
console.error('[AgentDetail] ERROR - Failed to start agent:', error);
|
||||
message.error('Failed to start agent');
|
||||
}
|
||||
};
|
||||
|
||||
const stopAgent = () => {
|
||||
console.log('[AgentDetail] Stopping agent execution');
|
||||
|
||||
try {
|
||||
console.log('[AgentDetail] Calling stopAgent on store');
|
||||
agentStore.stopAgent();
|
||||
console.log('[AgentDetail] Agent stop signal sent');
|
||||
message.success('Agent stop requested');
|
||||
} catch (error) {
|
||||
console.error('[AgentDetail] ERROR - Failed to stop agent:', error);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
console.log('[AgentDetail] Component mounted');
|
||||
console.log('[AgentDetail] Lifecycle: onMounted - starting initialization');
|
||||
|
||||
fetchAgent();
|
||||
|
||||
console.log(
|
||||
'[AgentDetail] Attempting WebSocket connection for agent:',
|
||||
agentId
|
||||
);
|
||||
try {
|
||||
agentStore.connect(agentId);
|
||||
console.log('[AgentDetail] WebSocket connection initiated');
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[AgentDetail] ERROR - Failed to connect WebSocket:',
|
||||
error
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
console.log('[AgentDetail] Component unmounted');
|
||||
console.log('[AgentDetail] Lifecycle: onUnmounted - cleaning up');
|
||||
|
||||
try {
|
||||
console.log('[AgentDetail] Disconnecting WebSocket');
|
||||
agentStore.disconnect();
|
||||
console.log('[AgentDetail] WebSocket disconnected successfully');
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[AgentDetail] ERROR - Failed to disconnect WebSocket:',
|
||||
error
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<header class="page-header">
|
||||
<h1>{{ agent.name }}</h1>
|
||||
<p class="lead">{{ agent.description }}</p>
|
||||
</header>
|
||||
<div class="page">
|
||||
<Card class="panel" :bordered="false">
|
||||
<div class="header">
|
||||
<Typography.Title :level="2">{{ agent.name }}</Typography.Title>
|
||||
<Tag
|
||||
:color="
|
||||
statusColor(
|
||||
String(agentStore.executionStatus || 'idle')
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
(agentStore.executionStatus || 'idle')
|
||||
.toString()
|
||||
.toUpperCase()
|
||||
}}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<section class="controls">
|
||||
<button class="cta-button">Run Simulation</button>
|
||||
<button class="link">Edit Configuration</button>
|
||||
</section>
|
||||
<Typography.Paragraph type="secondary">{{
|
||||
agent.description || 'No description available'
|
||||
}}</Typography.Paragraph>
|
||||
|
||||
<section class="logs">
|
||||
<h3>Recent Interactions</h3>
|
||||
<ul>
|
||||
<li>2025-11-01: Simulated onboarding session (score: 92%)</li>
|
||||
<li>2025-11-03: Updated knowledge sources</li>
|
||||
<li>2025-11-07: Minor behavior tweak applied</li>
|
||||
</ul>
|
||||
</section>
|
||||
<div class="connection-status">
|
||||
<span>WebSocket Status:</span>
|
||||
<Tag :color="agentStore.isConnected ? 'green' : 'red'">
|
||||
{{ agentStore.isConnected ? 'CONNECTED' : 'DISCONNECTED' }}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<Typography.Title :level="4" class="section-title"
|
||||
>Execution</Typography.Title
|
||||
>
|
||||
|
||||
<div class="execution-controls">
|
||||
<Space direction="vertical" style="width: 100%">
|
||||
<div>
|
||||
<Typography.Text>Query:</Typography.Text>
|
||||
<Input.TextArea
|
||||
v-model:value="queryInput"
|
||||
:disabled="isRunning"
|
||||
placeholder="Enter your query here..."
|
||||
:rows="4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
:disabled="isRunning || !isConnected"
|
||||
@click="startAgent"
|
||||
>
|
||||
Run Agent
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
:disabled="!isRunning"
|
||||
@click="stopAgent"
|
||||
>
|
||||
Stop Agent
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Typography.Title :level="4" class="section-title"
|
||||
>Execution Log</Typography.Title
|
||||
>
|
||||
|
||||
<Spin :spinning="isRunning" tip="Agent running...">
|
||||
<div class="log-container">
|
||||
<List
|
||||
v-if="(agentStore.eventLog?.length ?? 0) > 0"
|
||||
:data-source="agentStore.eventLog || []"
|
||||
:bordered="false"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<List.Item class="log-item">
|
||||
<div class="log-entry">
|
||||
<Tag class="log-type">{{ item.type }}</Tag>
|
||||
<span class="log-time">{{
|
||||
item.timestamp.toLocaleTimeString()
|
||||
}}</span>
|
||||
<div
|
||||
v-if="item.message"
|
||||
class="log-message"
|
||||
>
|
||||
{{ item.message }}
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
item.content &&
|
||||
typeof item.content === 'object'
|
||||
"
|
||||
class="log-content"
|
||||
>
|
||||
<pre>{{
|
||||
JSON.stringify(
|
||||
item.content,
|
||||
null,
|
||||
2
|
||||
)
|
||||
}}</pre>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.content"
|
||||
class="log-content"
|
||||
>
|
||||
{{ item.content }}
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
</template>
|
||||
</List>
|
||||
<Typography.Paragraph v-else type="secondary">
|
||||
No events yet. Start the agent to see execution logs.
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
<div v-if="agentResponse" class="response-section">
|
||||
<Typography.Title :level="4" class="section-title"
|
||||
>Response</Typography.Title
|
||||
>
|
||||
<Card class="response-card" :bordered="false">
|
||||
<div class="response-content">
|
||||
{{ agentResponse }}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-wrap {
|
||||
max-width: 960px;
|
||||
.page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.controls {
|
||||
|
||||
.panel {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-top: 2rem !important;
|
||||
margin-bottom: 1rem !important;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
padding: 0.5rem;
|
||||
background: #1f2937;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.execution-controls {
|
||||
background: #1f2937;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.cta-button {
|
||||
background-color: #4f46e5;
|
||||
color: white;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
|
||||
.log-container {
|
||||
background: #1f2937;
|
||||
border-radius: 4px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.link {
|
||||
background: transparent;
|
||||
color: #4f46e5;
|
||||
border: none;
|
||||
padding: 0.6rem 1rem;
|
||||
|
||||
.log-item {
|
||||
border-bottom: 1px solid #374151 !important;
|
||||
padding: 0.75rem !important;
|
||||
}
|
||||
.logs ul {
|
||||
|
||||
.log-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.log-type {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
color: #e5e7eb;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.log-content {
|
||||
background: #111827;
|
||||
padding: 0.5rem;
|
||||
border-radius: 3px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.log-content pre {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
color: #374151;
|
||||
font-size: 0.8rem;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.response-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.response-card {
|
||||
background: #1f2937;
|
||||
border: 1px solid #374151;
|
||||
}
|
||||
|
||||
.response-content {
|
||||
color: #e5e7eb;
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,61 +1,106 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { List, Typography, Button, Card, Spin, message } from 'ant-design-vue';
|
||||
import { apiClient } from '../lib/api';
|
||||
|
||||
const agents = ref([
|
||||
{ id: 'a1', name: 'Onboarding Bot', role: 'Guided tours' },
|
||||
{ id: 'a2', name: 'Docs Helper', role: 'Knowledge base' },
|
||||
{ id: 'a3', name: 'QA Coach', role: 'Assessment' },
|
||||
]);
|
||||
interface Agent {
|
||||
uuid: string;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const agents = ref<Agent[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const fetchAgents = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await apiClient.get('/api/agent/');
|
||||
agents.value = response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch agents:', error);
|
||||
message.error('Failed to load agents');
|
||||
agents.value = [
|
||||
{
|
||||
uuid: 'a1',
|
||||
id: 'a1',
|
||||
name: 'Onboarding Bot',
|
||||
description: 'Guided tours',
|
||||
status: 'idle',
|
||||
},
|
||||
{
|
||||
uuid: 'a2',
|
||||
id: 'a2',
|
||||
name: 'Docs Helper',
|
||||
description: 'Knowledge base',
|
||||
status: 'idle',
|
||||
},
|
||||
{
|
||||
uuid: 'a3',
|
||||
id: 'a3',
|
||||
name: 'QA Coach',
|
||||
description: 'Assessment',
|
||||
status: 'idle',
|
||||
},
|
||||
];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchAgents();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<header class="page-header">
|
||||
<h1>Agents</h1>
|
||||
<p class="lead">Manage and inspect the available AI agents.</p>
|
||||
</header>
|
||||
<div class="page">
|
||||
<Typography.Title :level="2">Agents</Typography.Title>
|
||||
<Typography.Paragraph type="secondary"
|
||||
>Manage and inspect the available AI agents.</Typography.Paragraph
|
||||
>
|
||||
|
||||
<section class="agent-list">
|
||||
<div v-for="agent in agents" :key="agent.id" class="agent-card">
|
||||
<div>
|
||||
<h3>{{ agent.name }}</h3>
|
||||
<p class="muted">{{ agent.role }}</p>
|
||||
</div>
|
||||
<router-link :to="`/agents/${agent.id}`" class="cta-small"
|
||||
>Open</router-link
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<Card class="panel" :bordered="false">
|
||||
<Spin :spinning="loading" tip="Loading agents...">
|
||||
<List
|
||||
:data-source="agents"
|
||||
item-layout="horizontal"
|
||||
:bordered="false"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<List.Item class="item">
|
||||
<List.Item.Meta
|
||||
:title="item.name"
|
||||
:description="`${item.description} • Status: ${item.status}`"
|
||||
/>
|
||||
<RouterLink :to="`/agents/${item.uuid || item.id}`">
|
||||
<Button type="primary" size="small"
|
||||
>Open</Button
|
||||
>
|
||||
</RouterLink>
|
||||
</List.Item>
|
||||
</template>
|
||||
</List>
|
||||
</Spin>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-wrap {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
.page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.agent-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
.panel {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.agent-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.muted {
|
||||
color: #6b7280;
|
||||
}
|
||||
.cta-small {
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
.item :deep(.ant-list-item-meta-title),
|
||||
.item :deep(.ant-list-item-meta-description) {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,71 +1,60 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Card, Typography, Row, Col, Tag, Button, Space } from 'ant-design-vue';
|
||||
|
||||
const assessments = ref([
|
||||
{ id: 't1', title: 'Knowledge Check - Basics', type: 'Quiz', passing: 70 },
|
||||
{
|
||||
id: 't2',
|
||||
title: 'Practical Task - Build Widget',
|
||||
type: 'Hands-on',
|
||||
passing: 80,
|
||||
},
|
||||
{ id: 't1', title: 'Knowledge Check - Basics', type: 'Quiz', passing: 70 },
|
||||
{
|
||||
id: 't2',
|
||||
title: 'Practical Task - Build Widget',
|
||||
type: 'Hands-on',
|
||||
passing: 80,
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<header class="page-header">
|
||||
<h1>Assessments</h1>
|
||||
<p class="lead">
|
||||
Create and run assessments to validate readiness.
|
||||
</p>
|
||||
</header>
|
||||
<div class="page">
|
||||
<Typography.Title :level="2">Assessments</Typography.Title>
|
||||
<Typography.Paragraph type="secondary"
|
||||
>Create and run assessments to validate
|
||||
readiness.</Typography.Paragraph
|
||||
>
|
||||
|
||||
<section class="assess-list">
|
||||
<div v-for="a in assessments" :key="a.id" class="assess-card">
|
||||
<h3>{{ a.title }}</h3>
|
||||
<p class="meta">
|
||||
Type: {{ a.type }} · Passing: {{ a.passing }}%
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button class="cta-small">Preview</button>
|
||||
<button class="link">Run</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<Row :gutter="16">
|
||||
<Col v-for="a in assessments" :key="a.id" :xs="24" :md="12">
|
||||
<Card class="card" hoverable :bordered="false">
|
||||
<Typography.Title :level="4">{{
|
||||
a.title
|
||||
}}</Typography.Title>
|
||||
<Typography.Text type="secondary"
|
||||
>Type: {{ a.type }} · Passing:
|
||||
{{ a.passing }}%</Typography.Text
|
||||
>
|
||||
<Space class="actions">
|
||||
<Tag color="blue">{{ a.type }}</Tag>
|
||||
<Button size="small">Preview</Button>
|
||||
<Button size="small" type="primary">Run</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.assess-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
.page {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.assess-card {
|
||||
background: #fff;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.meta {
|
||||
color: #6b7280;
|
||||
font-size: 0.95rem;
|
||||
.card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.cta-small {
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
}
|
||||
.link {
|
||||
background: transparent;
|
||||
color: #4f46e5;
|
||||
border: none;
|
||||
margin-top: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,87 +1,329 @@
|
|||
<script setup lang="ts" />
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Card,
|
||||
Button,
|
||||
Typography,
|
||||
Tag,
|
||||
Statistic,
|
||||
Carousel,
|
||||
Avatar,
|
||||
Space,
|
||||
Divider,
|
||||
} from 'ant-design-vue';
|
||||
import {
|
||||
CheckCircleTwoTone,
|
||||
ThunderboltTwoTone,
|
||||
CloudTwoTone,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const heroImage =
|
||||
'https://images.unsplash.com/photo-1521737604893-d14cc237f11d?auto=format&fit=crop&w=1400&q=80';
|
||||
|
||||
const stats = [
|
||||
{ title: 'Teams Onboarded', value: '240+' },
|
||||
{ title: 'Avg. Time Saved', value: '38%' },
|
||||
{ title: 'Playbooks Ready', value: '120' },
|
||||
];
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: 'Adaptive AI Guides',
|
||||
description:
|
||||
'Role-specific checklists, interactive tours, and contextual help tuned to your stack.',
|
||||
icon: CheckCircleTwoTone,
|
||||
},
|
||||
{
|
||||
title: 'Skills & Assessments',
|
||||
description:
|
||||
'Scenario-based quizzes and code tasks with instant insights and coach-like feedback.',
|
||||
icon: ThunderboltTwoTone,
|
||||
},
|
||||
{
|
||||
title: 'Knowledge Mesh',
|
||||
description:
|
||||
'Ingest docs, wikis, and reposâ€â€keep assistants current with zero manual updates.',
|
||||
icon: CloudTwoTone,
|
||||
},
|
||||
];
|
||||
|
||||
const journeys = [
|
||||
{
|
||||
name: 'Engineer Launch',
|
||||
steps: 'Access, environments, codebase tour, first PR, observability basics.',
|
||||
image: 'https://images.unsplash.com/photo-1522075469751-3a6694fb2f61?auto=format&fit=crop&w=800&q=80',
|
||||
},
|
||||
{
|
||||
name: 'Customer Success Ramp',
|
||||
steps: 'Playbooks, product scenarios, objection handling, success plans, CRM hygiene.',
|
||||
image: 'https://images.unsplash.com/photo-1521737604893-d14cc237f11d?auto=format&fit=crop&w=900&q=80',
|
||||
},
|
||||
{
|
||||
name: 'Product Discovery',
|
||||
steps: 'Interview templates, JTBD mapping, experiment cards, roadmap debates.',
|
||||
image: 'https://images.unsplash.com/photo-1483478550801-ceba5fe50e8e?auto=format&fit=crop&w=900&q=80',
|
||||
},
|
||||
];
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
name: 'Amira Chen',
|
||||
role: 'VP Engineering, Nimbus',
|
||||
quote: 'We cut onboarding from weeks to days. The guided flows and assessments keep everyone aligned.',
|
||||
avatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=200&q=80',
|
||||
},
|
||||
{
|
||||
name: 'Luis Ortega',
|
||||
role: 'Head of Success, Calypso',
|
||||
quote: 'Playbooks stay fresh automatically. New CSMs ship value on day one.',
|
||||
avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=200&q=80',
|
||||
},
|
||||
];
|
||||
|
||||
const logos = [
|
||||
'https://dummyimage.com/120x40/111827/ffffff&text=Nova',
|
||||
'https://dummyimage.com/120x40/1f2937/ffffff&text=Helio',
|
||||
'https://dummyimage.com/120x40/111827/ffffff&text=Arcus',
|
||||
'https://dummyimage.com/120x40/1f2937/ffffff&text=Vertex',
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="home">
|
||||
<section class="hero">
|
||||
<h1>Welcome to Agentic Trainers</h1>
|
||||
<p>
|
||||
Automate onboarding and support new team members with AI agents.
|
||||
Our platform creates domain-specific training workflows tailored
|
||||
to each role.
|
||||
</p>
|
||||
<router-link to="/about" class="cta-button">
|
||||
Learn More
|
||||
</router-link>
|
||||
</section>
|
||||
<main class="page">
|
||||
<section class="hero">
|
||||
<Row :gutter="32" :align="'middle'">
|
||||
<Col :xs="24" :md="14">
|
||||
<Typography.Title :level="1" class="hero-title">
|
||||
Build agentic onboarding that feels bespoke to every
|
||||
role
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph class="hero-sub">
|
||||
AI-led workflows, assessments, and knowledge delivery
|
||||
that adapt to your stack, your rituals, and your teams -
|
||||
so every new hire ships confidently, faster.
|
||||
</Typography.Paragraph>
|
||||
<Space>
|
||||
<RouterLink to="/about">
|
||||
<Button type="primary" size="large"
|
||||
>Learn More</Button
|
||||
>
|
||||
</RouterLink>
|
||||
<RouterLink to="/onboarding">
|
||||
<Button size="large">See Onboarding Flows</Button>
|
||||
</RouterLink>
|
||||
</Space>
|
||||
<Divider />
|
||||
<Row :gutter="16">
|
||||
<Col
|
||||
v-for="stat in stats"
|
||||
:key="stat.title"
|
||||
:xs="24"
|
||||
:sm="8"
|
||||
>
|
||||
<Card :bordered="false" class="stat-card" hoverable>
|
||||
<Statistic
|
||||
:title="stat.title"
|
||||
:value="stat.value"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col :xs="24" :md="10">
|
||||
<Card class="hero-card" hoverable :cover="null">
|
||||
<img
|
||||
:src="heroImage"
|
||||
alt="Team collaborating"
|
||||
class="hero-img"
|
||||
/>
|
||||
<div class="hero-overlay">Adaptive AI playbooks</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</section>
|
||||
|
||||
<section class="features">
|
||||
<h2>Key Features</h2>
|
||||
<ul>
|
||||
<li>Reusable AI-powered workflows for role induction.</li>
|
||||
<li>Adaptive guidance tailored to each team member.</li>
|
||||
<li>Track progress and generate actionable insights.</li>
|
||||
<li>Extensible to any domain or industry.</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="trusted">
|
||||
<Typography.Text type="secondary"
|
||||
>Trusted by modern teams</Typography.Text
|
||||
>
|
||||
<div class="logo-row">
|
||||
<img v-for="logo in logos" :key="logo" :src="logo" alt="logo" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="get-started">
|
||||
<h2>Get Started</h2>
|
||||
<p>
|
||||
Begin your AI-driven onboarding journey today. Explore how our
|
||||
agentic approach can help your team succeed faster.
|
||||
</p>
|
||||
<router-link to="/about" class="cta-button">
|
||||
Explore Now
|
||||
</router-link>
|
||||
</section>
|
||||
</main>
|
||||
<section class="features">
|
||||
<Typography.Title :level="2"
|
||||
>Everything you need to ramp faster</Typography.Title
|
||||
>
|
||||
<Row :gutter="16">
|
||||
<Col
|
||||
v-for="feature in features"
|
||||
:key="feature.title"
|
||||
:xs="24"
|
||||
:md="8"
|
||||
>
|
||||
<Card hoverable class="feature-card">
|
||||
<feature.icon
|
||||
two-tone-color="#8b5cf6"
|
||||
style="font-size: 28px"
|
||||
/>
|
||||
<Typography.Title :level="4">{{
|
||||
feature.title
|
||||
}}</Typography.Title>
|
||||
<Typography.Paragraph>{{
|
||||
feature.description
|
||||
}}</Typography.Paragraph>
|
||||
<Tag color="purple">Live</Tag>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</section>
|
||||
|
||||
<section class="journeys">
|
||||
<Typography.Title :level="2"
|
||||
>Prebuilt journeys, tailored in minutes</Typography.Title
|
||||
>
|
||||
<Row :gutter="16">
|
||||
<Col
|
||||
v-for="journey in journeys"
|
||||
:key="journey.name"
|
||||
:xs="24"
|
||||
:md="8"
|
||||
>
|
||||
<Card hoverable class="journey-card">
|
||||
<template #cover>
|
||||
<img :alt="journey.name" :src="journey.image" />
|
||||
</template>
|
||||
<Typography.Title :level="4">{{
|
||||
journey.name
|
||||
}}</Typography.Title>
|
||||
<Typography.Text>{{ journey.steps }}</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</section>
|
||||
|
||||
<section class="testimonials">
|
||||
<Typography.Title :level="2"
|
||||
>What teams are saying</Typography.Title
|
||||
>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style="display: block; text-align: center; margin-bottom: 1rem"
|
||||
>
|
||||
(Demo testimonials – real feedback coming soon)
|
||||
</Typography.Text>
|
||||
<Carousel autoplay dot-position="bottom">
|
||||
<div
|
||||
v-for="t in testimonials"
|
||||
:key="t.name"
|
||||
class="testimonial-slide"
|
||||
>
|
||||
<Card :bordered="false" class="testimonial-card">
|
||||
<Typography.Paragraph
|
||||
>“{{ t.quote }}â€Â</Typography.Paragraph
|
||||
>
|
||||
<Space>
|
||||
<Avatar :src="t.avatar" size="large" />
|
||||
<div>
|
||||
<div class="t-name">{{ t.name }}</div>
|
||||
<Typography.Text type="secondary">{{
|
||||
t.role
|
||||
}}</Typography.Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
</Carousel>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
.page {
|
||||
padding: 2rem 1.5rem 3rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.hero {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
.hero-title {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.hero p {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 2rem;
|
||||
.hero-sub {
|
||||
font-size: 1.05rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.cta-button {
|
||||
background-color: #4f46e5;
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
.hero-card {
|
||||
border: none;
|
||||
}
|
||||
.cta-button:hover {
|
||||
background-color: #4338ca;
|
||||
.hero-img {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.hero-overlay {
|
||||
margin-top: 0.75rem;
|
||||
color: #8b5cf6;
|
||||
font-weight: 600;
|
||||
}
|
||||
.stat-card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
}
|
||||
.trusted {
|
||||
text-align: center;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
.logo-row {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.logo-row img {
|
||||
opacity: 0.8;
|
||||
height: 32px;
|
||||
}
|
||||
.features {
|
||||
margin-bottom: 3rem;
|
||||
margin: 2.5rem 0;
|
||||
}
|
||||
.features h2 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
.feature-card {
|
||||
height: 100%;
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.features ul {
|
||||
list-style-type: disc;
|
||||
padding-left: 1.5rem;
|
||||
.journeys {
|
||||
margin: 2.5rem 0;
|
||||
}
|
||||
.get-started h2 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
.journey-card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.get-started p {
|
||||
margin-bottom: 1.5rem;
|
||||
.testimonials {
|
||||
margin: 2.5rem 0;
|
||||
}
|
||||
.testimonial-slide {
|
||||
padding: 0 6px;
|
||||
}
|
||||
.testimonial-card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.page {
|
||||
padding: 1.25rem 1rem 2.5rem;
|
||||
}
|
||||
.hero-img {
|
||||
height: 220px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,53 +1,106 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { reactive, computed, onMounted } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { Card, Typography, Form, Input, Button, message } from 'ant-design-vue';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
const router = useRouter();
|
||||
const name = ref('');
|
||||
const role = ref<'admin' | 'manager' | 'employee'>('employee');
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const loading = computed(() => authStore.loading);
|
||||
|
||||
function submit() {
|
||||
router.push('/');
|
||||
}
|
||||
const formState = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
await authStore.login(formState.email, formState.password);
|
||||
message.success('Login successful');
|
||||
const redirect = (route.query.redirect as string) || '/onboarding';
|
||||
router.push(redirect);
|
||||
} catch (error: any) {
|
||||
const errorMsg =
|
||||
authStore.error ||
|
||||
error?.response?.data?.detail ||
|
||||
error?.response?.data?.message ||
|
||||
'Login failed';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await authStore.fetchSession();
|
||||
if (authStore.isAuthenticated) {
|
||||
const redirect = (route.query.redirect as string) || '/onboarding';
|
||||
router.replace(redirect);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth">
|
||||
<h1>Login (demo)</h1>
|
||||
<div>
|
||||
<label>Name</label>
|
||||
<input v-model="name" placeholder="Your name" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Role</label>
|
||||
<select v-model="role">
|
||||
<option value="employee">Employee</option>
|
||||
<option value="manager">Manager</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button class="cta-button" @click="submit">Login</button>
|
||||
</div>
|
||||
<div class="auth-page">
|
||||
<Card class="panel" :bordered="false">
|
||||
<Typography.Title :level="3">Login</Typography.Title>
|
||||
<Form
|
||||
ref="form"
|
||||
layout="vertical"
|
||||
:model="formState"
|
||||
@finish="submit"
|
||||
>
|
||||
<Form.Item
|
||||
label="Email"
|
||||
name="email"
|
||||
:rules="[
|
||||
{ required: true, message: 'Enter your email' },
|
||||
{
|
||||
type: 'email',
|
||||
message: 'Please enter a valid email',
|
||||
},
|
||||
]"
|
||||
><Input
|
||||
v-model:value="formState.email"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
:disabled="loading"
|
||||
/></Form.Item>
|
||||
<Form.Item
|
||||
label="Password"
|
||||
name="password"
|
||||
:rules="[
|
||||
{ required: true, message: 'Enter your password' },
|
||||
]"
|
||||
><Input.Password
|
||||
v-model:value="formState.password"
|
||||
placeholder="Password"
|
||||
:disabled="loading"
|
||||
/></Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
block
|
||||
:loading="loading"
|
||||
>Login</Button
|
||||
>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
.auth-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.cta-button {
|
||||
margin-top: 1rem;
|
||||
.panel {
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Card, Typography, Timeline, Button, Space } from 'ant-design-vue';
|
||||
|
||||
const steps = ref([
|
||||
{
|
||||
|
|
@ -24,95 +25,63 @@ const steps = ref([
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<header class="page-header">
|
||||
<h1>Onboarding Flow</h1>
|
||||
<p class="lead">
|
||||
<div class="page">
|
||||
<Card class="panel" :bordered="false">
|
||||
<Typography.Title :level="2">Onboarding Flow</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
Step-by-step AI-guided onboarding for new team members.
|
||||
</p>
|
||||
</header>
|
||||
</Typography.Paragraph>
|
||||
|
||||
<section class="steps">
|
||||
<div v-for="step in steps" :key="step.id" class="card">
|
||||
<div class="card-left">
|
||||
<div class="step-index">{{ step.id }}</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>{{ step.title }}</h3>
|
||||
<p>{{ step.description }}</p>
|
||||
<div class="meta">Est. time: {{ step.eta }} mins</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<Timeline mode="left" class="timeline">
|
||||
<Timeline.Item
|
||||
v-for="step in steps"
|
||||
:key="step.id"
|
||||
color="purple"
|
||||
>
|
||||
<Space direction="vertical" size="small">
|
||||
<Typography.Title :level="4">{{
|
||||
step.title
|
||||
}}</Typography.Title>
|
||||
<Typography.Text>{{
|
||||
step.description
|
||||
}}</Typography.Text>
|
||||
<Typography.Text type="secondary"
|
||||
>Est. time: {{ step.eta }} mins</Typography.Text
|
||||
>
|
||||
</Space>
|
||||
</Timeline.Item>
|
||||
</Timeline>
|
||||
|
||||
<footer class="actions">
|
||||
<router-link to="/training" class="cta-button"
|
||||
>Start Training</router-link
|
||||
>
|
||||
<router-link to="/agents" class="link">View Agents</router-link>
|
||||
</footer>
|
||||
<Space class="actions" wrap>
|
||||
<RouterLink to="/training"
|
||||
><Button type="primary">Start Training</Button></RouterLink
|
||||
>
|
||||
<RouterLink to="/agents"
|
||||
><Button ghost>View Agents</Button></RouterLink
|
||||
>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-wrap {
|
||||
max-width: 960px;
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.page-header h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.25rem;
|
||||
.panel {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.lead {
|
||||
color: #6b7280;
|
||||
margin-bottom: 1rem;
|
||||
.timeline :deep(.ant-timeline-item-head) {
|
||||
background: #8b5cf6;
|
||||
}
|
||||
.steps {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.card-left {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
.step-index {
|
||||
background: #eef2ff;
|
||||
color: #4f46e5;
|
||||
font-weight: 700;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.card-body h3 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
.meta {
|
||||
color: #9ca3af;
|
||||
font-size: 0.9rem;
|
||||
.timeline :deep(.ant-typography) {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
.cta-button {
|
||||
background-color: #4f46e5;
|
||||
color: white;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.link {
|
||||
color: #4f46e5;
|
||||
align-self: center;
|
||||
text-decoration: none;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,90 +1,79 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { Card, Typography, Progress, List } from 'ant-design-vue';
|
||||
|
||||
const users = ref([
|
||||
{ id: 'u1', name: 'Alex', progress: 78 },
|
||||
{ id: 'u2', name: 'Priya', progress: 42 },
|
||||
{ id: 'u3', name: 'Miguel', progress: 96 },
|
||||
{ id: 'u1', name: 'Alex', progress: 78 },
|
||||
{ id: 'u2', name: 'Priya', progress: 42 },
|
||||
{ id: 'u3', name: 'Miguel', progress: 96 },
|
||||
]);
|
||||
|
||||
const avg = computed(() =>
|
||||
Math.round(
|
||||
users.value.reduce((s, u) => s + u.progress, 0) / users.value.length
|
||||
)
|
||||
Math.round(
|
||||
users.value.reduce((s, u) => s + u.progress, 0) / users.value.length
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<header class="page-header">
|
||||
<h1>Progress Dashboard</h1>
|
||||
<p class="lead">Track cohort progress and module completion.</p>
|
||||
</header>
|
||||
<div class="page">
|
||||
<Typography.Title :level="2">Progress Dashboard</Typography.Title>
|
||||
<Typography.Paragraph type="secondary"
|
||||
>Track cohort progress and module completion.</Typography.Paragraph
|
||||
>
|
||||
|
||||
<section class="overview">
|
||||
<div class="stat">
|
||||
<div class="stat-value">{{ avg }}%</div>
|
||||
<div class="stat-label">Average Progress</div>
|
||||
</div>
|
||||
<div class="user-list">
|
||||
<div v-for="u in users" :key="u.id" class="user-row">
|
||||
<div>{{ u.name }}</div>
|
||||
<div class="progress">
|
||||
<div
|
||||
class="bar"
|
||||
:style="{ width: u.progress + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="pct">{{ u.progress }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="overview">
|
||||
<Card class="stat" :bordered="false">
|
||||
<Typography.Title :level="3">{{ avg }}%</Typography.Title>
|
||||
<Typography.Text type="secondary"
|
||||
>Average Progress</Typography.Text
|
||||
>
|
||||
</Card>
|
||||
|
||||
<Card class="panel" :bordered="false">
|
||||
<List :data-source="users" :bordered="false">
|
||||
<template #renderItem="{ item }">
|
||||
<List.Item class="user-row">
|
||||
<div class="user-name">{{ item.name }}</div>
|
||||
<Progress
|
||||
:percent="item.progress"
|
||||
stroke-color="#8b5cf6"
|
||||
:show-info="true"
|
||||
/>
|
||||
</List.Item>
|
||||
</template>
|
||||
</List>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.overview {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.stat {
|
||||
background: #fff;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
}
|
||||
.user-list {
|
||||
background: #fff;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
.stat,
|
||||
.panel {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.user-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.progress {
|
||||
flex: 1;
|
||||
background: #f3f4f6;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
.user-name {
|
||||
width: 140px;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.bar {
|
||||
height: 100%;
|
||||
background: #4f46e5;
|
||||
}
|
||||
.pct {
|
||||
width: 48px;
|
||||
text-align: right;
|
||||
color: #374151;
|
||||
.panel :deep(.ant-progress-text) {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,52 +1,172 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { reactive, computed, onMounted } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import {
|
||||
Card,
|
||||
Typography,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Button,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
const router = useRouter();
|
||||
const name = ref('');
|
||||
const role = ref<'admin' | 'manager' | 'employee'>('employee');
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const loading = computed(() => authStore.loading);
|
||||
|
||||
function submit() {
|
||||
router.push('/');
|
||||
}
|
||||
const formState = reactive({
|
||||
email: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
role: 'employee' as 'admin' | 'manager' | 'employee',
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
await authStore.register({
|
||||
email_address: formState.email,
|
||||
password: formState.password,
|
||||
confirm_password: formState.confirmPassword,
|
||||
first_name: formState.firstName,
|
||||
last_name: formState.lastName,
|
||||
role: formState.role,
|
||||
});
|
||||
message.success('Account created');
|
||||
const redirect = (route.query.redirect as string) || '/onboarding';
|
||||
router.push(redirect);
|
||||
} catch (error: any) {
|
||||
const errorMsg =
|
||||
authStore.error ||
|
||||
error?.response?.data?.detail ||
|
||||
error?.response?.data?.message ||
|
||||
'Registration failed';
|
||||
message.error(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await authStore.fetchSession();
|
||||
if (authStore.isAuthenticated) {
|
||||
const redirect = (route.query.redirect as string) || '/onboarding';
|
||||
router.replace(redirect);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth">
|
||||
<h1>Register (demo)</h1>
|
||||
<div>
|
||||
<label>Name</label>
|
||||
<input v-model="name" placeholder="Your name" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Role</label>
|
||||
<select v-model="role">
|
||||
<option value="employee">Employee</option>
|
||||
<option value="manager">Manager</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button class="cta-button" @click="submit">Register</button>
|
||||
</div>
|
||||
<div class="auth-page">
|
||||
<Card class="panel" :bordered="false">
|
||||
<Typography.Title :level="3">Register</Typography.Title>
|
||||
<Form layout="vertical" :model="formState" @finish="submit">
|
||||
<Form.Item
|
||||
label="Email"
|
||||
name="email"
|
||||
:rules="[
|
||||
{ required: true, message: 'Enter your email' },
|
||||
{
|
||||
type: 'email',
|
||||
message: 'Please enter a valid email',
|
||||
},
|
||||
]"
|
||||
>
|
||||
<Input
|
||||
v-model:value="formState.email"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="First name"
|
||||
name="firstName"
|
||||
:rules="[
|
||||
{ required: true, message: 'Enter your first name' },
|
||||
]"
|
||||
>
|
||||
<Input
|
||||
v-model:value="formState.firstName"
|
||||
placeholder="First name"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Last name"
|
||||
name="lastName"
|
||||
:rules="[
|
||||
{ required: true, message: 'Enter your last name' },
|
||||
]"
|
||||
>
|
||||
<Input
|
||||
v-model:value="formState.lastName"
|
||||
placeholder="Last name"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Password"
|
||||
name="password"
|
||||
:rules="[{ required: true, message: 'Create a password' }]"
|
||||
>
|
||||
<Input.Password
|
||||
v-model:value="formState.password"
|
||||
placeholder="Password"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Confirm password"
|
||||
name="confirmPassword"
|
||||
:rules="[
|
||||
{ required: true, message: 'Confirm your password' },
|
||||
{
|
||||
validator: (_, value) =>
|
||||
value === formState.password
|
||||
? Promise.resolve()
|
||||
: Promise.reject(
|
||||
new Error('Passwords do not match')
|
||||
),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<Input.Password
|
||||
v-model:value="formState.confirmPassword"
|
||||
placeholder="Confirm password"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Role" name="role">
|
||||
<Select v-model:value="formState.role" :disabled="loading">
|
||||
<Select.Option value="employee">Employee</Select.Option>
|
||||
<Select.Option value="manager">Manager</Select.Option>
|
||||
<Select.Option value="admin">Admin</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
block
|
||||
:loading="loading"
|
||||
>Register</Button
|
||||
>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
.auth-page {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.cta-button {
|
||||
margin-top: 1rem;
|
||||
.panel {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,55 +1,49 @@
|
|||
<script setup lang="ts">
|
||||
import { Card, Typography, Row, Col, Button, Tag } from 'ant-design-vue';
|
||||
|
||||
const resources = [
|
||||
{ id: 'r1', title: 'Engineering Handbook', type: 'PDF' },
|
||||
{ id: 'r2', title: 'Team Slack Guide', type: 'Article' },
|
||||
{ id: 'r3', title: 'Codebase Tour', type: 'Video' },
|
||||
{ id: 'r1', title: 'Engineering Handbook', type: 'PDF' },
|
||||
{ id: 'r2', title: 'Team Slack Guide', type: 'Article' },
|
||||
{ id: 'r3', title: 'Codebase Tour', type: 'Video' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<header class="page-header">
|
||||
<h1>Resources</h1>
|
||||
<p class="lead">
|
||||
Curated links and assets to help new hires get productive.
|
||||
</p>
|
||||
</header>
|
||||
<div class="page">
|
||||
<Typography.Title :level="2">Resources</Typography.Title>
|
||||
<Typography.Paragraph type="secondary"
|
||||
>Curated links and assets to help new hires get
|
||||
productive.</Typography.Paragraph
|
||||
>
|
||||
|
||||
<section class="resource-grid">
|
||||
<div v-for="r in resources" :key="r.id" class="resource-card">
|
||||
<h3>{{ r.title }}</h3>
|
||||
<p class="muted">{{ r.type }}</p>
|
||||
<div class="actions">
|
||||
<button class="cta-small">Open</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<Row :gutter="16">
|
||||
<Col v-for="r in resources" :key="r.id" :xs="24" :md="8">
|
||||
<Card class="card" hoverable :bordered="false">
|
||||
<Typography.Title :level="4">{{
|
||||
r.title
|
||||
}}</Typography.Title>
|
||||
<Tag color="geekblue">{{ r.type }}</Tag>
|
||||
<div class="actions">
|
||||
<Button size="small" type="primary">Open</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.resource-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
.page {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.resource-card {
|
||||
background: #fff;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.muted {
|
||||
color: #6b7280;
|
||||
.card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.cta-small {
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,66 +1,71 @@
|
|||
<script setup lang="ts">
|
||||
import { Card, Typography, Row, Col, Button, Tag } from 'ant-design-vue';
|
||||
|
||||
const roles = [
|
||||
{
|
||||
id: 'r1',
|
||||
title: 'Frontend Engineer',
|
||||
summary: 'Focus on UI, UX and frontend platform integrations.',
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
title: 'Product Manager',
|
||||
summary: 'Define goals, priorities, and track success metrics.',
|
||||
},
|
||||
{
|
||||
id: 'r3',
|
||||
title: 'Customer Success',
|
||||
summary: 'Onboard customers and reduce time-to-value.',
|
||||
},
|
||||
{
|
||||
id: 'r1',
|
||||
title: 'Frontend Engineer',
|
||||
summary: 'Focus on UI, UX and frontend platform integrations.',
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
title: 'Product Manager',
|
||||
summary: 'Define goals, priorities, and track success metrics.',
|
||||
},
|
||||
{
|
||||
id: 'r3',
|
||||
title: 'Customer Success',
|
||||
summary: 'Onboard customers and reduce time-to-value.',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<header class="page-header">
|
||||
<h1>Role Profiles</h1>
|
||||
<p class="lead">
|
||||
Pre-built role templates and suggested onboarding paths.
|
||||
</p>
|
||||
</header>
|
||||
<div class="page">
|
||||
<Typography.Title :level="2">Role Profiles</Typography.Title>
|
||||
<Typography.Paragraph type="secondary"
|
||||
>Pre-built role templates and suggested onboarding
|
||||
paths.</Typography.Paragraph
|
||||
>
|
||||
|
||||
<section class="profiles">
|
||||
<div v-for="role in roles" :key="role.id" class="profile-card">
|
||||
<h3>{{ role.title }}</h3>
|
||||
<p>{{ role.summary }}</p>
|
||||
<div class="actions">
|
||||
<router-link :to="`/onboarding`" class="cta-small"
|
||||
>Use Template</router-link
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<Row :gutter="16">
|
||||
<Col v-for="role in roles" :key="role.id" :xs="24" :md="8">
|
||||
<Card class="card" hoverable :bordered="false">
|
||||
<Typography.Title :level="4">{{
|
||||
role.title
|
||||
}}</Typography.Title>
|
||||
<Typography.Paragraph>{{
|
||||
role.summary
|
||||
}}</Typography.Paragraph>
|
||||
<div class="actions">
|
||||
<Tag color="purple">Template</Tag>
|
||||
<RouterLink to="/onboarding"
|
||||
><Button type="primary" size="small"
|
||||
>Use Template</Button
|
||||
></RouterLink
|
||||
>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1rem;
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.profile-card {
|
||||
background: #fff;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
.card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.cta-small {
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,94 +1,71 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Card, Typography, Row, Col, Tag, Button } from 'ant-design-vue';
|
||||
|
||||
const lessons = ref([
|
||||
{
|
||||
id: 'l1',
|
||||
title: 'Getting Started',
|
||||
summary: 'Overview of the codebase and conventions.',
|
||||
type: 'Video',
|
||||
},
|
||||
{
|
||||
id: 'l2',
|
||||
title: 'Core Concepts',
|
||||
summary: 'Key patterns and architecture.',
|
||||
type: 'Article',
|
||||
},
|
||||
{
|
||||
id: 'l3',
|
||||
title: 'Hands-on Lab',
|
||||
summary: 'Small task to practice.',
|
||||
type: 'Practical',
|
||||
},
|
||||
{
|
||||
id: 'l1',
|
||||
title: 'Getting Started',
|
||||
summary: 'Overview of the codebase and conventions.',
|
||||
type: 'Video',
|
||||
},
|
||||
{
|
||||
id: 'l2',
|
||||
title: 'Core Concepts',
|
||||
summary: 'Key patterns and architecture.',
|
||||
type: 'Article',
|
||||
},
|
||||
{
|
||||
id: 'l3',
|
||||
title: 'Hands-on Lab',
|
||||
summary: 'Small task to practice.',
|
||||
type: 'Practical',
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<header class="page-header">
|
||||
<h1>Training Module</h1>
|
||||
<p class="lead">Interactive module with lessons and checkpoints.</p>
|
||||
</header>
|
||||
<div class="page">
|
||||
<Typography.Title :level="2">Training Module</Typography.Title>
|
||||
<Typography.Paragraph type="secondary"
|
||||
>Interactive module with lessons and
|
||||
checkpoints.</Typography.Paragraph
|
||||
>
|
||||
|
||||
<section class="lessons">
|
||||
<article
|
||||
v-for="lesson in lessons"
|
||||
:key="lesson.id"
|
||||
class="lesson-card"
|
||||
>
|
||||
<h3>{{ lesson.title }}</h3>
|
||||
<p>{{ lesson.summary }}</p>
|
||||
<div class="lesson-footer">
|
||||
<span class="badge">{{ lesson.type }}</span>
|
||||
<button class="cta-small">Open</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
<Row :gutter="16">
|
||||
<Col v-for="lesson in lessons" :key="lesson.id" :xs="24" :md="8">
|
||||
<Card class="card" hoverable :bordered="false">
|
||||
<Typography.Title :level="4">{{
|
||||
lesson.title
|
||||
}}</Typography.Title>
|
||||
<Typography.Paragraph>{{
|
||||
lesson.summary
|
||||
}}</Typography.Paragraph>
|
||||
<div class="lesson-footer">
|
||||
<Tag color="purple">{{ lesson.type }}</Tag>
|
||||
<Button type="primary" size="small">Open</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-wrap {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.page-header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.lead {
|
||||
color: #6b7280;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.lessons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.lesson-card {
|
||||
background: #fff;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
.card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.lesson-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.badge {
|
||||
background: #eef2ff;
|
||||
color: #4f46e5;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cta-small {
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
6
src/vue-shims.d.ts
vendored
6
src/vue-shims.d.ts
vendored
|
|
@ -1,5 +1,5 @@
|
|||
declare module '*.vue' {
|
||||
import { defineComponent } from 'vue';
|
||||
const component: ReturnType<typeof defineComponent>;
|
||||
export default component;
|
||||
import { defineComponent } from 'vue';
|
||||
const component: ReturnType<typeof defineComponent>;
|
||||
export default component;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue