Added pages for frontend

This commit is contained in:
Viswamedha Nalabotu 2025-12-17 14:47:51 +00:00
parent a10632e4bf
commit a5f039d021
21 changed files with 2268 additions and 916 deletions

View file

@ -4,7 +4,8 @@
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "vite dev", "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, "private": true,
"dependencies": { "dependencies": {

View file

@ -3,17 +3,17 @@ import { computed, onMounted } from 'vue';
import { Layout, Menu, Button, Space, Typography } from 'ant-design-vue'; import { Layout, Menu, Button, Space, Typography } from 'ant-design-vue';
import type { MenuProps } from 'ant-design-vue'; import type { MenuProps } from 'ant-design-vue';
import { import {
HomeOutlined, HomeOutlined,
InfoCircleOutlined, InfoCircleOutlined,
RocketOutlined, RocketOutlined,
ReadOutlined, ReadOutlined,
TeamOutlined, TeamOutlined,
RobotOutlined, RobotOutlined,
BulbOutlined, BulbOutlined,
AppstoreOutlined, AppstoreOutlined,
DashboardOutlined, DashboardOutlined,
LoginOutlined, LoginOutlined,
UserAddOutlined, UserAddOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
@ -23,193 +23,193 @@ const route = useRoute();
const authStore = useAuthStore(); const authStore = useAuthStore();
const navItems = [ const navItems = [
{ {
key: '/', key: '/',
label: 'Home', label: 'Home',
icon: HomeOutlined, icon: HomeOutlined,
path: '/', path: '/',
}, },
{ {
key: '/about', key: '/about',
label: 'About', label: 'About',
icon: InfoCircleOutlined, icon: InfoCircleOutlined,
path: '/about', path: '/about',
}, },
{ {
key: '/onboarding', key: '/onboarding',
label: 'Onboarding', label: 'Onboarding',
icon: RocketOutlined, icon: RocketOutlined,
path: '/onboarding', path: '/onboarding',
}, },
{ {
key: '/training', key: '/training',
label: 'Training', label: 'Training',
icon: ReadOutlined, icon: ReadOutlined,
path: '/training', path: '/training',
}, },
{ {
key: '/roles', key: '/roles',
label: 'Roles', label: 'Roles',
icon: TeamOutlined, icon: TeamOutlined,
path: '/roles', path: '/roles',
roles: ['manager', 'admin'], roles: ['manager', 'admin'],
}, },
{ {
key: '/agents', key: '/agents',
label: 'Agents', label: 'Agents',
icon: RobotOutlined, icon: RobotOutlined,
path: '/agents', path: '/agents',
roles: ['manager', 'admin'], roles: ['manager', 'admin'],
}, },
{ {
key: '/assessments', key: '/assessments',
label: 'Assessments', label: 'Assessments',
icon: BulbOutlined, icon: BulbOutlined,
path: '/assessments', path: '/assessments',
}, },
{ {
key: '/resources', key: '/resources',
label: 'Resources', label: 'Resources',
icon: AppstoreOutlined, icon: AppstoreOutlined,
path: '/resources', path: '/resources',
}, },
{ {
key: '/progress', key: '/progress',
label: 'Progress', label: 'Progress',
icon: DashboardOutlined, icon: DashboardOutlined,
path: '/progress', path: '/progress',
}, },
]; ];
const visibleNavItems = computed(() => const visibleNavItems = computed(() =>
navItems.filter((item) => navItems.filter((item) =>
item.roles ? authStore.hasRole(item.roles) : true item.roles ? authStore.hasRole(item.roles) : true
) )
); );
const selectedKeys = computed(() => { const selectedKeys = computed(() => {
const match = visibleNavItems.value.find((item) => const match = visibleNavItems.value.find((item) =>
route.path.startsWith(item.key) route.path.startsWith(item.key)
); );
return match ? [match.key] : []; return match ? [match.key] : [];
}); });
const onSelect: MenuProps['onSelect'] = ({ key }) => { const onSelect: MenuProps['onSelect'] = ({ key }) => {
const item = visibleNavItems.value.find((n) => n.key === key); const item = visibleNavItems.value.find((n) => n.key === key);
if (item) router.push(item.path); if (item) router.push(item.path);
}; };
const handleLogout = async () => { const handleLogout = async () => {
await authStore.logout(); await authStore.logout();
router.push('/'); router.push('/');
}; };
onMounted(() => { onMounted(() => {
authStore.fetchSession(); authStore.fetchSession();
}); });
</script> </script>
<template> <template>
<Layout class="shell"> <Layout class="shell">
<Layout.Header class="shell-header"> <Layout.Header class="shell-header">
<div class="brand" @click="router.push('/')">Dynavera</div> <div class="brand" @click="router.push('/')">Dynavera</div>
<Menu <Menu
mode="horizontal" mode="horizontal"
theme="dark" theme="dark"
:selectedKeys="selectedKeys" :selectedKeys="selectedKeys"
class="shell-menu" class="shell-menu"
@select="onSelect" @select="onSelect"
> >
<Menu.Item v-for="item in visibleNavItems" :key="item.key"> <Menu.Item v-for="item in visibleNavItems" :key="item.key">
<Space size="small"> <Space size="small">
<component :is="item.icon" /> <component :is="item.icon" />
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
</Space> </Space>
</Menu.Item> </Menu.Item>
</Menu> </Menu>
<Space> <Space>
<template v-if="authStore.isAuthenticated"> <template v-if="authStore.isAuthenticated">
<Typography.Text class="user-chip" strong> <Typography.Text class="user-chip" strong>
{{ authStore.displayName || 'Account' }} {{ authStore.displayName || 'Account' }}
</Typography.Text> </Typography.Text>
<Button <Button
ghost ghost
:loading="authStore.loading" :loading="authStore.loading"
@click="handleLogout" @click="handleLogout"
> >
Logout Logout
</Button> </Button>
</template> </template>
<template v-else> <template v-else>
<Button ghost @click="router.push('/login')"> <Button ghost @click="router.push('/login')">
<LoginOutlined /> Login <LoginOutlined /> Login
</Button> </Button>
<Button type="primary" @click="router.push('/register')"> <Button type="primary" @click="router.push('/register')">
<UserAddOutlined /> Register <UserAddOutlined /> Register
</Button> </Button>
</template> </template>
</Space> </Space>
</Layout.Header> </Layout.Header>
<Layout class="shell-body"> <Layout class="shell-body">
<Layout.Content class="shell-content"> <Layout.Content class="shell-content">
<router-view /> <router-view />
</Layout.Content> </Layout.Content>
<Layout.Footer class="shell-footer"> <Layout.Footer class="shell-footer">
<Typography.Text type="secondary"> <Typography.Text type="secondary">
<strong>Project Disclaimer:</strong> This is a <strong>Project Disclaimer:</strong> This is a
proof-of-concept demo project for educational purposes. All proof-of-concept demo project for educational purposes. All
testimonials, statistics, and company names are fictional testimonials, statistics, and company names are fictional
placeholders. placeholders.
</Typography.Text> </Typography.Text>
</Layout.Footer> </Layout.Footer>
</Layout> </Layout>
</Layout> </Layout>
</template> </template>
<style scoped> <style scoped>
.shell { .shell {
min-height: 100vh; min-height: 100vh;
background: #0b1220; background: #0b1220;
} }
.shell-header { .shell-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
padding: 0 1.25rem; padding: 0 1.25rem;
background: #0f172a; background: #0f172a;
} }
.brand { .brand {
color: #e5e7eb; color: #e5e7eb;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
font-size: 1.05rem; font-size: 1.05rem;
} }
.shell-menu { .shell-menu {
flex: 1; flex: 1;
background: transparent; background: transparent;
border-bottom: none; border-bottom: none;
} }
.shell-body { .shell-body {
background: #0b1220; background: #0b1220;
min-height: calc(100vh - 64px); min-height: calc(100vh - 64px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.shell-content { .shell-content {
padding: 24px; padding: 24px;
flex: 1; flex: 1;
min-height: calc(100vh - 64px - 64px); min-height: calc(100vh - 64px - 64px);
} }
.shell-footer { .shell-footer {
text-align: center; text-align: center;
background: #0f172a; background: #0f172a;
} }
:deep(.ant-menu-dark) { :deep(.ant-menu-dark) {
background: transparent; background: transparent;
} }
:deep(.ant-menu-dark .ant-menu-item-selected) { :deep(.ant-menu-dark .ant-menu-item-selected) {
background: transparent !important; background: transparent !important;
} }
:deep(.ant-typography), :deep(.ant-typography),
:deep(.ant-typography p), :deep(.ant-typography p),
@ -221,41 +221,41 @@ onMounted(() => {
:deep(.ant-statistic-content), :deep(.ant-statistic-content),
:deep(.ant-card-meta-title), :deep(.ant-card-meta-title),
:deep(.ant-card-meta-description) { :deep(.ant-card-meta-description) {
color: #e5e7eb; color: #e5e7eb;
} }
:deep(.ant-typography-secondary) { :deep(.ant-typography-secondary) {
color: #cbd5e1 !important; color: #cbd5e1 !important;
} }
:deep(.ant-form-item-label > label) { :deep(.ant-form-item-label > label) {
color: #e5e7eb; color: #e5e7eb;
} }
:deep(.ant-input), :deep(.ant-input),
:deep(.ant-select-selector), :deep(.ant-select-selector),
:deep(.ant-select-selection-item), :deep(.ant-select-selection-item),
:deep(.ant-picker-input input) { :deep(.ant-picker-input input) {
background: #111827; background: #111827;
color: #e5e7eb; color: #e5e7eb;
border-color: #334155; border-color: #334155;
} }
:deep(.ant-input::placeholder), :deep(.ant-input::placeholder),
:deep(.ant-select-selection-placeholder), :deep(.ant-select-selection-placeholder),
:deep(.ant-picker-input input::placeholder) { :deep(.ant-picker-input input::placeholder) {
color: #9ca3af; color: #9ca3af;
} }
:deep(.ant-card) { :deep(.ant-card) {
background: #0f172a; background: #0f172a;
border-color: #1f2937; border-color: #1f2937;
} }
:deep(.ant-btn:not(.ant-btn-primary)) { :deep(.ant-btn:not(.ant-btn-primary)) {
color: #e5e7eb; color: #e5e7eb;
border-color: #334155; border-color: #334155;
background: #111827; background: #111827;
} }
:deep(.ant-btn-primary) { :deep(.ant-btn-primary) {
background: linear-gradient(90deg, #6366f1, #8b5cf6); background: linear-gradient(90deg, #6366f1, #8b5cf6);
border: none; border: none;
} }
.user-chip { .user-chip {
color: #e5e7eb; color: #e5e7eb;
} }
</style> </style>

67
src/lib/api.ts Normal file
View 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';

View file

@ -1,8 +1,11 @@
import './styles.css'; import './styles.css';
import 'ant-design-vue/dist/reset.css';
import router from './router'; import router from './router';
import { createApp } from 'vue'; import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './app/App.vue'; import App from './app/App.vue';
const app = createApp(App); const app = createApp(App);
app.use(createPinia());
app.use(router); app.use(router);
app.mount('#root'); app.mount('#root');

View file

@ -1,69 +1,110 @@
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '../stores/authStore';
import { message } from 'ant-design-vue';
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ routes: [
{ {
path: '/', path: '/',
name: 'home', name: 'home',
component: () => import('../views/HomeView.vue'), component: () => import('../views/HomeView.vue'),
}, },
{ {
path: '/about', path: '/about',
name: 'about', name: 'about',
component: () => import('../views/AboutView.vue'), component: () => import('../views/AboutView.vue'),
}, },
{ {
path: '/login', path: '/login',
name: 'login', name: 'login',
component: () => import('../views/LoginView.vue'), component: () => import('../views/LoginView.vue'),
}, meta: { guestOnly: true },
{ },
path: '/register', {
name: 'register', path: '/register',
component: () => import('../views/RegisterView.vue'), name: 'register',
}, component: () => import('../views/RegisterView.vue'),
{ meta: { guestOnly: true },
path: '/onboarding', },
name: 'onboarding', {
component: () => import('../views/OnboardingFlow.vue'), path: '/onboarding',
}, name: 'onboarding',
{ component: () => import('../views/OnboardingFlow.vue'),
path: '/training/:moduleId?', meta: { requiresAuth: true },
name: 'training', },
component: () => import('../views/TrainingModule.vue'), {
}, path: '/training/:moduleId?',
{ name: 'training',
path: '/agents', component: () => import('../views/TrainingModule.vue'),
name: 'agents', meta: { requiresAuth: true },
component: () => import('../views/Agents.vue'), },
}, {
{ path: '/agents',
path: '/agents/:id', name: 'agents',
name: 'agent-detail', component: () => import('../views/Agents.vue'),
component: () => import('../views/AgentDetail.vue'), meta: { requiresAuth: true, roles: ['manager', 'admin'] },
}, },
{ {
path: '/roles', path: '/agents/:id',
name: 'roles', name: 'agent-detail',
component: () => import('../views/RoleProfiles.vue'), component: () => import('../views/AgentDetail.vue'),
}, meta: { requiresAuth: true, roles: ['manager', 'admin'] },
{ },
path: '/progress', {
name: 'progress', path: '/roles',
component: () => import('../views/ProgressDashboard.vue'), name: 'roles',
}, component: () => import('../views/RoleProfiles.vue'),
{ meta: { requiresAuth: true, roles: ['manager', 'admin'] },
path: '/assessments', },
name: 'assessments', {
component: () => import('../views/Assessments.vue'), path: '/progress',
}, name: 'progress',
{ component: () => import('../views/ProgressDashboard.vue'),
path: '/resources', meta: { requiresAuth: true },
name: 'resources', },
component: () => import('../views/Resources.vue'), {
}, 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; 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
View 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
View 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,
};
});

View file

@ -1,41 +1,44 @@
html { html {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
line-height: 1.5; 'Noto Color Emoji';
tab-size: 4; line-height: 1.5;
scroll-behavior: smooth; tab-size: 4;
scroll-behavior: smooth;
} }
body { body {
font-family: inherit; font-family: inherit;
line-height: inherit; line-height: inherit;
margin: 0; margin: 0;
background: #0b1220;
color: #e5e7eb;
} }
h1, h1,
h2, h2,
p, p,
pre { pre {
margin: 0; margin: 0;
} }
*, *,
::before, ::before,
::after { ::after {
box-sizing: border-box; box-sizing: border-box;
border-width: 0; border-width: 0;
border-style: solid; border-style: solid;
border-color: currentColor; border-color: currentColor;
} }
h1, h1,
h2 { h2 {
font-size: inherit; font-size: inherit;
font-weight: inherit; font-weight: inherit;
} }
a { a {
color: inherit; color: inherit;
text-decoration: inherit; text-decoration: inherit;
} }
pre { pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace; 'Liberation Mono', 'Courier New', monospace;
} }

View file

@ -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> <template>
<div class="about"> <div class="page">
<h1>About Agentic Trainers</h1> <Card class="panel" :bordered="false">
<p> <Typography.Title :level="2"
Agentic Trainers is a lightweight platform for onboarding, training, >About Agentic Trainers</Typography.Title
and assessing employees using modular training content and >
agent-driven workflows. This repo contains a demo front-end and <Typography.Paragraph type="secondary">
example pipelines for role-based experiences. Agentic Trainers is a lightweight platform for onboarding,
</p> 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> <Divider />
<ul> <Typography.Title :level="4">Role pathways</Typography.Title>
<li> <List :data-source="pathways" :bordered="false">
<strong>Admin</strong>: full access to system settings, user <template #renderItem="{ item }">
management, and reporting. Admins can invite or deactivate <List.Item class="row">{{ item }}</List.Item>
managers and view overall progress. </template>
</li> </List>
<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>
<h2>Getting started</h2> <Divider />
<ol> <Typography.Title :level="4">Highlights</Typography.Title>
<li> <List :data-source="highlights" :bordered="false">
Register or login from the top-right to choose your role (demo <template #renderItem="{ item }">
only). <List.Item class="row">{{ item }}</List.Item>
</li> </template>
<li> </List>
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>
<p> <Divider />
This is a demo implementation authentication and permissions are <Typography.Title :level="4">Getting started</Typography.Title>
local-storage based for example purposes. For production-grade apps <List :data-source="steps" :bordered="false">
integrate a backend auth provider and persistent user store. <template #renderItem="{ item, index }">
</p> <List.Item class="row"
</div> ><strong>{{ index + 1 }}.</strong>&nbsp;{{
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> </template>
<style> <style scoped>
@media (min-width: 768px) { .page {
.about { max-width: 900px;
max-width: 768px; margin: 0 auto;
margin-left: auto; padding: 1rem;
margin-right: auto; }
padding: 0 1rem; .panel {
} background: #0f172a;
border: 1px solid #1f2937;
color: #e5e7eb;
}
.row {
color: #e5e7eb;
} }
</style> </style>

View file

@ -1,68 +1,421 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref, onMounted, onUnmounted, computed } from 'vue';
import { useRoute } from 'vue-router'; 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 route = useRoute();
const id = route.params.id || 'unknown'; console.log('[AgentDetail] Route params:', route.params);
const agent = ref({ const agentStore = useAgentStore();
id, console.log('[AgentDetail] Store instance:', agentStore);
name: `Agent ${id}`,
description: const agentId = route.params.id as string;
'This agent helps new hires by providing step-by-step assistance and quick answers to common questions.', 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> </script>
<template> <template>
<div class="page-wrap"> <div class="page">
<header class="page-header"> <Card class="panel" :bordered="false">
<h1>{{ agent.name }}</h1> <div class="header">
<p class="lead">{{ agent.description }}</p> <Typography.Title :level="2">{{ agent.name }}</Typography.Title>
</header> <Tag
:color="
statusColor(
String(agentStore.executionStatus || 'idle')
)
"
>
{{
(agentStore.executionStatus || 'idle')
.toString()
.toUpperCase()
}}
</Tag>
</div>
<section class="controls"> <Typography.Paragraph type="secondary">{{
<button class="cta-button">Run Simulation</button> agent.description || 'No description available'
<button class="link">Edit Configuration</button> }}</Typography.Paragraph>
</section>
<section class="logs"> <div class="connection-status">
<h3>Recent Interactions</h3> <span>WebSocket Status:</span>
<ul> <Tag :color="agentStore.isConnected ? 'green' : 'red'">
<li>2025-11-01: Simulated onboarding session (score: 92%)</li> {{ agentStore.isConnected ? 'CONNECTED' : 'DISCONNECTED' }}
<li>2025-11-03: Updated knowledge sources</li> </Tag>
<li>2025-11-07: Minor behavior tweak applied</li> </div>
</ul>
</section> <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> </div>
</template> </template>
<style scoped> <style scoped>
.page-wrap { .page {
max-width: 960px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 1rem;
} }
.controls {
.panel {
background: #0f172a;
border: 1px solid #1f2937;
color: #e5e7eb;
}
.header {
display: flex; 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; margin: 1rem 0;
} }
.cta-button {
background-color: #4f46e5; .log-container {
color: white; background: #1f2937;
padding: 0.6rem 1rem; border-radius: 4px;
border-radius: 0.5rem; max-height: 500px;
border: none; overflow-y: auto;
} }
.link {
background: transparent; .log-item {
color: #4f46e5; border-bottom: 1px solid #374151 !important;
border: none; padding: 0.75rem !important;
padding: 0.6rem 1rem;
} }
.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; margin: 0;
padding-left: 1.25rem; font-size: 0.8rem;
color: #374151; 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> </style>

View file

@ -1,61 +1,106 @@
<script setup lang="ts"> <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([ interface Agent {
{ id: 'a1', name: 'Onboarding Bot', role: 'Guided tours' }, uuid: string;
{ id: 'a2', name: 'Docs Helper', role: 'Knowledge base' }, id: string;
{ id: 'a3', name: 'QA Coach', role: 'Assessment' }, 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> </script>
<template> <template>
<div class="page-wrap"> <div class="page">
<header class="page-header"> <Typography.Title :level="2">Agents</Typography.Title>
<h1>Agents</h1> <Typography.Paragraph type="secondary"
<p class="lead">Manage and inspect the available AI agents.</p> >Manage and inspect the available AI agents.</Typography.Paragraph
</header> >
<section class="agent-list"> <Card class="panel" :bordered="false">
<div v-for="agent in agents" :key="agent.id" class="agent-card"> <Spin :spinning="loading" tip="Loading agents...">
<div> <List
<h3>{{ agent.name }}</h3> :data-source="agents"
<p class="muted">{{ agent.role }}</p> item-layout="horizontal"
</div> :bordered="false"
<router-link :to="`/agents/${agent.id}`" class="cta-small" >
>Open</router-link <template #renderItem="{ item }">
> <List.Item class="item">
</div> <List.Item.Meta
</section> :title="item.name"
</div> :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> </template>
<style scoped> <style scoped>
.page-wrap { .page {
max-width: 960px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 1rem;
} }
.agent-list { .panel {
display: grid; background: #0f172a;
gap: 0.75rem; border: 1px solid #1f2937;
color: #e5e7eb;
} }
.agent-card { .item :deep(.ant-list-item-meta-title),
display: flex; .item :deep(.ant-list-item-meta-description) {
justify-content: space-between; color: #e5e7eb;
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;
} }
</style> </style>

View file

@ -1,71 +1,60 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { Card, Typography, Row, Col, Tag, Button, Space } from 'ant-design-vue';
const assessments = ref([ const assessments = ref([
{ id: 't1', title: 'Knowledge Check - Basics', type: 'Quiz', passing: 70 }, { id: 't1', title: 'Knowledge Check - Basics', type: 'Quiz', passing: 70 },
{ {
id: 't2', id: 't2',
title: 'Practical Task - Build Widget', title: 'Practical Task - Build Widget',
type: 'Hands-on', type: 'Hands-on',
passing: 80, passing: 80,
}, },
]); ]);
</script> </script>
<template> <template>
<div class="page-wrap"> <div class="page">
<header class="page-header"> <Typography.Title :level="2">Assessments</Typography.Title>
<h1>Assessments</h1> <Typography.Paragraph type="secondary"
<p class="lead"> >Create and run assessments to validate
Create and run assessments to validate readiness. readiness.</Typography.Paragraph
</p> >
</header>
<section class="assess-list"> <Row :gutter="16">
<div v-for="a in assessments" :key="a.id" class="assess-card"> <Col v-for="a in assessments" :key="a.id" :xs="24" :md="12">
<h3>{{ a.title }}</h3> <Card class="card" hoverable :bordered="false">
<p class="meta"> <Typography.Title :level="4">{{
Type: {{ a.type }} · Passing: {{ a.passing }}% a.title
</p> }}</Typography.Title>
<div class="actions"> <Typography.Text type="secondary"
<button class="cta-small">Preview</button> >Type: {{ a.type }} ÷ Passing:
<button class="link">Run</button> {{ a.passing }}%</Typography.Text
</div> >
</div> <Space class="actions">
</section> <Tag color="blue">{{ a.type }}</Tag>
</div> <Button size="small">Preview</Button>
<Button size="small" type="primary">Run</Button>
</Space>
</Card>
</Col>
</Row>
</div>
</template> </template>
<style scoped> <style scoped>
.assess-list { .page {
display: grid; max-width: 1000px;
gap: 0.75rem; margin: 0 auto;
padding: 1rem;
} }
.assess-card { .card {
background: #fff; background: #0f172a;
padding: 0.75rem; border: 1px solid #1f2937;
border-radius: 8px; color: #e5e7eb;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.meta {
color: #6b7280;
font-size: 0.95rem;
} }
.actions { .actions {
margin-top: 0.5rem; margin-top: 0.75rem;
display: flex; gap: 0.5rem;
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;
} }
</style> </style>

View file

@ -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> <template>
<main class="home"> <main class="page">
<section class="hero"> <section class="hero">
<h1>Welcome to Agentic Trainers</h1> <Row :gutter="32" :align="'middle'">
<p> <Col :xs="24" :md="14">
Automate onboarding and support new team members with AI agents. <Typography.Title :level="1" class="hero-title">
Our platform creates domain-specific training workflows tailored Build agentic onboarding that feels bespoke to every
to each role. role
</p> </Typography.Title>
<router-link to="/about" class="cta-button"> <Typography.Paragraph class="hero-sub">
Learn More AI-led workflows, assessments, and knowledge delivery
</router-link> that adapt to your stack, your rituals, and your teams -
</section> 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"> <section class="trusted">
<h2>Key Features</h2> <Typography.Text type="secondary"
<ul> >Trusted by modern teams</Typography.Text
<li>Reusable AI-powered workflows for role induction.</li> >
<li>Adaptive guidance tailored to each team member.</li> <div class="logo-row">
<li>Track progress and generate actionable insights.</li> <img v-for="logo in logos" :key="logo" :src="logo" alt="logo" />
<li>Extensible to any domain or industry.</li> </div>
</ul> </section>
</section>
<section class="get-started"> <section class="features">
<h2>Get Started</h2> <Typography.Title :level="2"
<p> >Everything you need to ramp faster</Typography.Title
Begin your AI-driven onboarding journey today. Explore how our >
agentic approach can help your team succeed faster. <Row :gutter="16">
</p> <Col
<router-link to="/about" class="cta-button"> v-for="feature in features"
Explore Now :key="feature.title"
</router-link> :xs="24"
</section> :md="8"
</main> >
<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> </template>
<style scoped> <style scoped>
.home { .page {
max-width: 960px; padding: 2rem 1.5rem 3rem;
margin: 0 auto; max-width: 1200px;
padding: 1rem; margin: 0 auto;
} }
.hero { .hero {
text-align: center; margin-bottom: 2.5rem;
margin-bottom: 3rem;
} }
.hero h1 { .hero-title {
font-size: 2.5rem; margin-bottom: 1rem;
margin-bottom: 1rem;
} }
.hero p { .hero-sub {
font-size: 1.25rem; font-size: 1.05rem;
margin-bottom: 2rem; color: #cbd5e1;
} }
.cta-button { .hero-card {
background-color: #4f46e5; border: none;
color: white;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
text-decoration: none;
font-weight: 600;
} }
.cta-button:hover { .hero-img {
background-color: #4338ca; 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 { .features {
margin-bottom: 3rem; margin: 2.5rem 0;
} }
.features h2 { .feature-card {
font-size: 2rem; height: 100%;
margin-bottom: 1rem; background: #0f172a;
border: 1px solid #1f2937;
color: #e5e7eb;
} }
.features ul { .journeys {
list-style-type: disc; margin: 2.5rem 0;
padding-left: 1.5rem;
} }
.get-started h2 { .journey-card {
font-size: 2rem; background: #0f172a;
margin-bottom: 1rem; border: 1px solid #1f2937;
color: #e5e7eb;
} }
.get-started p { .testimonials {
margin-bottom: 1.5rem; 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> </style>

View file

@ -1,53 +1,106 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { reactive, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router'; 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 router = useRouter();
const name = ref(''); const route = useRoute();
const role = ref<'admin' | 'manager' | 'employee'>('employee'); const authStore = useAuthStore();
const loading = computed(() => authStore.loading);
function submit() { const formState = reactive({
router.push('/'); 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> </script>
<template> <template>
<div class="auth"> <div class="auth-page">
<h1>Login (demo)</h1> <Card class="panel" :bordered="false">
<div> <Typography.Title :level="3">Login</Typography.Title>
<label>Name</label> <Form
<input v-model="name" placeholder="Your name" /> ref="form"
</div> layout="vertical"
:model="formState"
<div> @finish="submit"
<label>Role</label> >
<select v-model="role"> <Form.Item
<option value="employee">Employee</option> label="Email"
<option value="manager">Manager</option> name="email"
<option value="admin">Admin</option> :rules="[
</select> { required: true, message: 'Enter your email' },
</div> {
type: 'email',
<button class="cta-button" @click="submit">Login</button> message: 'Please enter a valid email',
</div> },
]"
><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> </template>
<style scoped> <style scoped>
.auth { .auth-page {
max-width: 480px; display: flex;
margin: 0 auto; align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
} }
label { .panel {
display: block; max-width: 400px;
margin-top: 0.5rem; width: 100%;
} background: #0f172a;
input, border: 1px solid #1f2937;
select { color: #e5e7eb;
width: 100%;
padding: 0.5rem;
margin-top: 0.25rem;
}
.cta-button {
margin-top: 1rem;
} }
</style> </style>

View file

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { Card, Typography, Timeline, Button, Space } from 'ant-design-vue';
const steps = ref([ const steps = ref([
{ {
@ -24,95 +25,63 @@ const steps = ref([
</script> </script>
<template> <template>
<div class="page-wrap"> <div class="page">
<header class="page-header"> <Card class="panel" :bordered="false">
<h1>Onboarding Flow</h1> <Typography.Title :level="2">Onboarding Flow</Typography.Title>
<p class="lead"> <Typography.Paragraph type="secondary">
Step-by-step AI-guided onboarding for new team members. Step-by-step AI-guided onboarding for new team members.
</p> </Typography.Paragraph>
</header>
<section class="steps"> <Timeline mode="left" class="timeline">
<div v-for="step in steps" :key="step.id" class="card"> <Timeline.Item
<div class="card-left"> v-for="step in steps"
<div class="step-index">{{ step.id }}</div> :key="step.id"
</div> color="purple"
<div class="card-body"> >
<h3>{{ step.title }}</h3> <Space direction="vertical" size="small">
<p>{{ step.description }}</p> <Typography.Title :level="4">{{
<div class="meta">Est. time: {{ step.eta }} mins</div> step.title
</div> }}</Typography.Title>
</div> <Typography.Text>{{
</section> step.description
}}</Typography.Text>
<Typography.Text type="secondary"
>Est. time: {{ step.eta }} mins</Typography.Text
>
</Space>
</Timeline.Item>
</Timeline>
<footer class="actions"> <Space class="actions" wrap>
<router-link to="/training" class="cta-button" <RouterLink to="/training"
>Start Training</router-link ><Button type="primary">Start Training</Button></RouterLink
> >
<router-link to="/agents" class="link">View Agents</router-link> <RouterLink to="/agents"
</footer> ><Button ghost>View Agents</Button></RouterLink
>
</Space>
</Card>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.page-wrap { .page {
max-width: 960px; max-width: 1100px;
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 1rem;
} }
.page-header h1 { .panel {
font-size: 2rem; background: #0f172a;
margin-bottom: 0.25rem; border: 1px solid #1f2937;
color: #e5e7eb;
} }
.lead { .timeline :deep(.ant-timeline-item-head) {
color: #6b7280; background: #8b5cf6;
margin-bottom: 1rem;
} }
.steps { .timeline :deep(.ant-typography) {
display: grid; color: #e5e7eb;
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;
} }
.actions { .actions {
display: flex; margin-top: 1rem;
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;
} }
</style> </style>

View file

@ -1,90 +1,79 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { Card, Typography, Progress, List } from 'ant-design-vue';
const users = ref([ const users = ref([
{ id: 'u1', name: 'Alex', progress: 78 }, { id: 'u1', name: 'Alex', progress: 78 },
{ id: 'u2', name: 'Priya', progress: 42 }, { id: 'u2', name: 'Priya', progress: 42 },
{ id: 'u3', name: 'Miguel', progress: 96 }, { id: 'u3', name: 'Miguel', progress: 96 },
]); ]);
const avg = computed(() => const avg = computed(() =>
Math.round( Math.round(
users.value.reduce((s, u) => s + u.progress, 0) / users.value.length users.value.reduce((s, u) => s + u.progress, 0) / users.value.length
) )
); );
</script> </script>
<template> <template>
<div class="page-wrap"> <div class="page">
<header class="page-header"> <Typography.Title :level="2">Progress Dashboard</Typography.Title>
<h1>Progress Dashboard</h1> <Typography.Paragraph type="secondary"
<p class="lead">Track cohort progress and module completion.</p> >Track cohort progress and module completion.</Typography.Paragraph
</header> >
<section class="overview"> <div class="overview">
<div class="stat"> <Card class="stat" :bordered="false">
<div class="stat-value">{{ avg }}%</div> <Typography.Title :level="3">{{ avg }}%</Typography.Title>
<div class="stat-label">Average Progress</div> <Typography.Text type="secondary"
</div> >Average Progress</Typography.Text
<div class="user-list"> >
<div v-for="u in users" :key="u.id" class="user-row"> </Card>
<div>{{ u.name }}</div>
<div class="progress"> <Card class="panel" :bordered="false">
<div <List :data-source="users" :bordered="false">
class="bar" <template #renderItem="{ item }">
:style="{ width: u.progress + '%' }" <List.Item class="user-row">
></div> <div class="user-name">{{ item.name }}</div>
</div> <Progress
<div class="pct">{{ u.progress }}%</div> :percent="item.progress"
</div> stroke-color="#8b5cf6"
</div> :show-info="true"
</section> />
</div> </List.Item>
</template>
</List>
</Card>
</div>
</div>
</template> </template>
<style scoped> <style scoped>
.page {
max-width: 900px;
margin: 0 auto;
padding: 1rem;
}
.overview { .overview {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
} }
.stat { .stat,
background: #fff; .panel {
padding: 1rem; background: #0f172a;
border-radius: 8px; border: 1px solid #1f2937;
text-align: center; color: #e5e7eb;
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);
} }
.user-row { .user-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 1rem;
padding: 0.5rem 0;
} }
.progress { .user-name {
flex: 1; width: 140px;
background: #f3f4f6; color: #e5e7eb;
height: 10px;
border-radius: 999px;
overflow: hidden;
} }
.bar { .panel :deep(.ant-progress-text) {
height: 100%; color: #e5e7eb;
background: #4f46e5;
}
.pct {
width: 48px;
text-align: right;
color: #374151;
} }
</style> </style>

View file

@ -1,52 +1,172 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { reactive, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router'; 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 router = useRouter();
const name = ref(''); const route = useRoute();
const role = ref<'admin' | 'manager' | 'employee'>('employee'); const authStore = useAuthStore();
const loading = computed(() => authStore.loading);
function submit() { const formState = reactive({
router.push('/'); 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> </script>
<template> <template>
<div class="auth"> <div class="auth-page">
<h1>Register (demo)</h1> <Card class="panel" :bordered="false">
<div> <Typography.Title :level="3">Register</Typography.Title>
<label>Name</label> <Form layout="vertical" :model="formState" @finish="submit">
<input v-model="name" placeholder="Your name" /> <Form.Item
</div> label="Email"
name="email"
<div> :rules="[
<label>Role</label> { required: true, message: 'Enter your email' },
<select v-model="role"> {
<option value="employee">Employee</option> type: 'email',
<option value="manager">Manager</option> message: 'Please enter a valid email',
</select> },
</div> ]"
>
<button class="cta-button" @click="submit">Register</button> <Input
</div> 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> </template>
<style scoped> <style scoped>
.auth { .auth-page {
max-width: 480px; max-width: 520px;
margin: 0 auto; margin: 0 auto;
padding: 1rem;
} }
label { .panel {
display: block; background: #0f172a;
margin-top: 0.5rem; border: 1px solid #1f2937;
} color: #e5e7eb;
input,
select {
width: 100%;
padding: 0.5rem;
margin-top: 0.25rem;
}
.cta-button {
margin-top: 1rem;
} }
</style> </style>

View file

@ -1,55 +1,49 @@
<script setup lang="ts"> <script setup lang="ts">
import { Card, Typography, Row, Col, Button, Tag } from 'ant-design-vue';
const resources = [ const resources = [
{ id: 'r1', title: 'Engineering Handbook', type: 'PDF' }, { id: 'r1', title: 'Engineering Handbook', type: 'PDF' },
{ id: 'r2', title: 'Team Slack Guide', type: 'Article' }, { id: 'r2', title: 'Team Slack Guide', type: 'Article' },
{ id: 'r3', title: 'Codebase Tour', type: 'Video' }, { id: 'r3', title: 'Codebase Tour', type: 'Video' },
]; ];
</script> </script>
<template> <template>
<div class="page-wrap"> <div class="page">
<header class="page-header"> <Typography.Title :level="2">Resources</Typography.Title>
<h1>Resources</h1> <Typography.Paragraph type="secondary"
<p class="lead"> >Curated links and assets to help new hires get
Curated links and assets to help new hires get productive. productive.</Typography.Paragraph
</p> >
</header>
<section class="resource-grid"> <Row :gutter="16">
<div v-for="r in resources" :key="r.id" class="resource-card"> <Col v-for="r in resources" :key="r.id" :xs="24" :md="8">
<h3>{{ r.title }}</h3> <Card class="card" hoverable :bordered="false">
<p class="muted">{{ r.type }}</p> <Typography.Title :level="4">{{
<div class="actions"> r.title
<button class="cta-small">Open</button> }}</Typography.Title>
</div> <Tag color="geekblue">{{ r.type }}</Tag>
</div> <div class="actions">
</section> <Button size="small" type="primary">Open</Button>
</div> </div>
</Card>
</Col>
</Row>
</div>
</template> </template>
<style scoped> <style scoped>
.resource-grid { .page {
display: grid; max-width: 1000px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); margin: 0 auto;
gap: 1rem; padding: 1rem;
} }
.resource-card { .card {
background: #fff; background: #0f172a;
padding: 0.75rem; border: 1px solid #1f2937;
border-radius: 8px; color: #e5e7eb;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.muted {
color: #6b7280;
} }
.actions { .actions {
margin-top: 0.5rem; margin-top: 0.5rem;
}
.cta-small {
background: #4f46e5;
color: #fff;
padding: 0.4rem 0.6rem;
border-radius: 6px;
border: none;
} }
</style> </style>

View file

@ -1,66 +1,71 @@
<script setup lang="ts"> <script setup lang="ts">
import { Card, Typography, Row, Col, Button, Tag } from 'ant-design-vue';
const roles = [ const roles = [
{ {
id: 'r1', id: 'r1',
title: 'Frontend Engineer', title: 'Frontend Engineer',
summary: 'Focus on UI, UX and frontend platform integrations.', summary: 'Focus on UI, UX and frontend platform integrations.',
}, },
{ {
id: 'r2', id: 'r2',
title: 'Product Manager', title: 'Product Manager',
summary: 'Define goals, priorities, and track success metrics.', summary: 'Define goals, priorities, and track success metrics.',
}, },
{ {
id: 'r3', id: 'r3',
title: 'Customer Success', title: 'Customer Success',
summary: 'Onboard customers and reduce time-to-value.', summary: 'Onboard customers and reduce time-to-value.',
}, },
]; ];
</script> </script>
<template> <template>
<div class="page-wrap"> <div class="page">
<header class="page-header"> <Typography.Title :level="2">Role Profiles</Typography.Title>
<h1>Role Profiles</h1> <Typography.Paragraph type="secondary"
<p class="lead"> >Pre-built role templates and suggested onboarding
Pre-built role templates and suggested onboarding paths. paths.</Typography.Paragraph
</p> >
</header>
<section class="profiles"> <Row :gutter="16">
<div v-for="role in roles" :key="role.id" class="profile-card"> <Col v-for="role in roles" :key="role.id" :xs="24" :md="8">
<h3>{{ role.title }}</h3> <Card class="card" hoverable :bordered="false">
<p>{{ role.summary }}</p> <Typography.Title :level="4">{{
<div class="actions"> role.title
<router-link :to="`/onboarding`" class="cta-small" }}</Typography.Title>
>Use Template</router-link <Typography.Paragraph>{{
> role.summary
</div> }}</Typography.Paragraph>
</div> <div class="actions">
</section> <Tag color="purple">Template</Tag>
</div> <RouterLink to="/onboarding"
><Button type="primary" size="small"
>Use Template</Button
></RouterLink
>
</div>
</Card>
</Col>
</Row>
</div>
</template> </template>
<style scoped> <style scoped>
.profiles { .page {
display: grid; max-width: 1100px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); margin: 0 auto;
gap: 1rem; padding: 1rem;
} }
.profile-card { .card {
background: #fff; background: #0f172a;
padding: 0.75rem; border: 1px solid #1f2937;
border-radius: 8px; color: #e5e7eb;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
} }
.actions { .actions {
margin-top: 0.75rem; display: flex;
} justify-content: space-between;
.cta-small { align-items: center;
background: #4f46e5; margin-top: 0.75rem;
color: #fff;
padding: 0.4rem 0.6rem;
border-radius: 6px;
text-decoration: none;
} }
</style> </style>

View file

@ -1,94 +1,71 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { Card, Typography, Row, Col, Tag, Button } from 'ant-design-vue';
const lessons = ref([ const lessons = ref([
{ {
id: 'l1', id: 'l1',
title: 'Getting Started', title: 'Getting Started',
summary: 'Overview of the codebase and conventions.', summary: 'Overview of the codebase and conventions.',
type: 'Video', type: 'Video',
}, },
{ {
id: 'l2', id: 'l2',
title: 'Core Concepts', title: 'Core Concepts',
summary: 'Key patterns and architecture.', summary: 'Key patterns and architecture.',
type: 'Article', type: 'Article',
}, },
{ {
id: 'l3', id: 'l3',
title: 'Hands-on Lab', title: 'Hands-on Lab',
summary: 'Small task to practice.', summary: 'Small task to practice.',
type: 'Practical', type: 'Practical',
}, },
]); ]);
</script> </script>
<template> <template>
<div class="page-wrap"> <div class="page">
<header class="page-header"> <Typography.Title :level="2">Training Module</Typography.Title>
<h1>Training Module</h1> <Typography.Paragraph type="secondary"
<p class="lead">Interactive module with lessons and checkpoints.</p> >Interactive module with lessons and
</header> checkpoints.</Typography.Paragraph
>
<section class="lessons"> <Row :gutter="16">
<article <Col v-for="lesson in lessons" :key="lesson.id" :xs="24" :md="8">
v-for="lesson in lessons" <Card class="card" hoverable :bordered="false">
:key="lesson.id" <Typography.Title :level="4">{{
class="lesson-card" lesson.title
> }}</Typography.Title>
<h3>{{ lesson.title }}</h3> <Typography.Paragraph>{{
<p>{{ lesson.summary }}</p> lesson.summary
<div class="lesson-footer"> }}</Typography.Paragraph>
<span class="badge">{{ lesson.type }}</span> <div class="lesson-footer">
<button class="cta-small">Open</button> <Tag color="purple">{{ lesson.type }}</Tag>
</div> <Button type="primary" size="small">Open</Button>
</article> </div>
</section> </Card>
</div> </Col>
</Row>
</div>
</template> </template>
<style scoped> <style scoped>
.page-wrap { .page {
max-width: 960px; max-width: 1100px;
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 1rem;
} }
.page-header h1 { .card {
font-size: 2rem; background: #0f172a;
} border: 1px solid #1f2937;
.lead { color: #e5e7eb;
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);
} }
.lesson-footer { .lesson-footer {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-top: 0.75rem; 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;
} }
</style> </style>

6
src/vue-shims.d.ts vendored
View file

@ -1,5 +1,5 @@
declare module '*.vue' { declare module '*.vue' {
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
const component: ReturnType<typeof defineComponent>; const component: ReturnType<typeof defineComponent>;
export default component; export default component;
} }