This commit is contained in:
xds
2026-03-16 12:12:56 +03:00
commit 9d886076d6
63 changed files with 4482 additions and 0 deletions

31
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
import { computed } from 'vue'
import { useAuthStore } from './stores/auth'
import Button from 'primevue/button'
import Avatar from 'primevue/avatar'
const auth = useAuthStore()
const isAuthenticated = computed(() => auth.isAuthenticated)
</script>
<template>
<div class="min-h-screen bg-surface-950 text-surface-0">
<nav v-if="isAuthenticated" class="flex items-center justify-between px-8 h-14 bg-surface-900 border-b border-surface-700">
<RouterLink to="/" class="text-xl font-bold text-primary">VeloBrain</RouterLink>
<div class="flex items-center gap-6">
<RouterLink to="/" class="text-surface-400 hover:text-surface-0 text-sm transition-colors" active-class="!text-surface-0">Dashboard</RouterLink>
<RouterLink to="/activities" class="text-surface-400 hover:text-surface-0 text-sm transition-colors" active-class="!text-surface-0">Activities</RouterLink>
<RouterLink to="/settings" class="text-surface-400 hover:text-surface-0 text-sm transition-colors" active-class="!text-surface-0">Settings</RouterLink>
<div class="flex items-center gap-3 ml-4">
<Avatar v-if="auth.rider?.avatar_url" :image="auth.rider.avatar_url" shape="circle" size="small" />
<Avatar v-else :label="auth.rider?.name?.charAt(0) ?? '?'" shape="circle" size="small" />
<Button icon="pi pi-sign-out" text rounded severity="secondary" size="small" @click="auth.logout()" />
</div>
</div>
</nav>
<main class="max-w-[1200px] mx-auto p-8">
<RouterView />
</main>
</div>
</template>

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,28 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
})
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('access_token')
window.location.href = '/login'
}
return Promise.reject(error)
},
)
export function useApi() {
return { api }
}

26
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,26 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Aura from '@primeuix/themes/aura'
import router from './router'
import App from './App.vue'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(PrimeVue, {
theme: {
preset: Aura,
options: {
cssLayer: {
name: 'primevue',
order: 'theme, base, primevue',
},
darkModeSelector: '.app-dark',
},
},
})
app.mount('#app')

52
frontend/src/router.ts Normal file
View File

@@ -0,0 +1,52 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from './stores/auth'
const routes = [
{
path: '/login',
name: 'login',
component: () => import('./views/LoginView.vue'),
meta: { requiresAuth: false },
},
{
path: '/',
name: 'dashboard',
component: () => import('./views/DashboardView.vue'),
meta: { requiresAuth: true },
},
{
path: '/activities',
name: 'activities',
component: () => import('./views/ActivitiesView.vue'),
meta: { requiresAuth: true },
},
{
path: '/activities/:id',
name: 'activity-detail',
component: () => import('./views/ActivityDetailView.vue'),
meta: { requiresAuth: true },
},
{
path: '/settings',
name: 'settings',
component: () => import('./views/SettingsView.vue'),
meta: { requiresAuth: true },
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.meta.requiresAuth !== false && !auth.isAuthenticated) {
return { name: 'login' }
}
if (to.name === 'login' && auth.isAuthenticated) {
return { name: 'dashboard' }
}
})
export default router

View File

@@ -0,0 +1,32 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi'
import type { Activity } from '../types/models'
export const useActivitiesStore = defineStore('activities', () => {
const { api } = useApi()
const activities = ref<Activity[]>([])
const total = ref(0)
async function fetchActivities(riderId: string, limit = 20, offset = 0) {
const { data } = await api.get('/activities', {
params: { rider_id: riderId, limit, offset },
})
activities.value = data.items
total.value = data.total
}
async function uploadFit(riderId: string, file: File) {
const form = new FormData()
form.append('file', file)
const { data } = await api.post<Activity>(
`/activities/upload?rider_id=${riderId}`,
form,
)
activities.value.unshift(data)
total.value++
return data
}
return { activities, total, fetchActivities, uploadFit }
})

View File

@@ -0,0 +1,45 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi'
import type { Rider } from '../types/models'
import router from '../router'
interface AuthResponse {
access_token: string
token_type: string
rider: Rider
}
export const useAuthStore = defineStore('auth', () => {
const { api } = useApi()
const token = ref<string | null>(localStorage.getItem('access_token'))
const rider = ref<Rider | null>(null)
const isAuthenticated = computed(() => !!token.value)
function setAuth(response: AuthResponse) {
token.value = response.access_token
rider.value = response.rider
localStorage.setItem('access_token', response.access_token)
}
function logout() {
token.value = null
rider.value = null
localStorage.removeItem('access_token')
router.push({ name: 'login' })
}
async function loginWithTelegram(data: TelegramLoginData) {
const { data: response } = await api.post<AuthResponse>('/auth/telegram-login', data)
setAuth(response)
router.push({ name: 'dashboard' })
}
async function loginWithWebApp(initData: string) {
const { data: response } = await api.post<AuthResponse>('/auth/telegram-webapp', { init_data: initData })
setAuth(response)
router.push({ name: 'dashboard' })
}
return { token, rider, isAuthenticated, loginWithTelegram, loginWithWebApp, logout }
})

View File

@@ -0,0 +1,21 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi'
import type { Rider } from '../types/models'
export const useRiderStore = defineStore('rider', () => {
const { api } = useApi()
const rider = ref<Rider | null>(null)
async function fetchRider(id: string) {
const { data } = await api.get<Rider>(`/rider/profile/${id}`)
rider.value = data
}
async function updateRider(id: string, updates: Partial<Rider>) {
const { data } = await api.put<Rider>(`/rider/profile/${id}`, updates)
rider.value = data
}
return { rider, fetchRider, updateRider }
})

3
frontend/src/style.css Normal file
View File

@@ -0,0 +1,3 @@
@import "tailwindcss";
@plugin "tailwindcss-primeui";
@import "primeicons/primeicons.css";

View File

@@ -0,0 +1,50 @@
export interface Rider {
id: string
telegram_id: number | null
telegram_username: string | null
avatar_url: string | null
name: string
ftp: number | null
lthr: number | null
weight: number | null
zones_config: Record<string, unknown> | null
goals: string | null
experience_level: string | null
}
export interface ActivityMetrics {
tss: number | null
normalized_power: number | null
intensity_factor: number | null
variability_index: number | null
avg_power: number | null
max_power: number | null
avg_hr: number | null
max_hr: number | null
avg_cadence: number | null
avg_speed: number | null
}
export interface Activity {
id: string
rider_id: string
name: string | null
activity_type: string
date: string
duration: number
distance: number | null
elevation_gain: number | null
metrics: ActivityMetrics | null
}
export interface DataPoint {
timestamp: string
power: number | null
heart_rate: number | null
cadence: number | null
speed: number | null
latitude: number | null
longitude: number | null
altitude: number | null
temperature: number | null
}

31
frontend/src/types/telegram.d.ts vendored Normal file
View File

@@ -0,0 +1,31 @@
interface TelegramWebApp {
initData: string
initDataUnsafe: {
user?: {
id: number
first_name: string
last_name?: string
username?: string
photo_url?: string
}
}
ready(): void
close(): void
expand(): void
}
interface Window {
Telegram?: {
WebApp?: TelegramWebApp
}
}
interface TelegramLoginData {
id: number
first_name: string
last_name?: string
username?: string
photo_url?: string
auth_date: number
hash: string
}

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import Card from 'primevue/card'
import Button from 'primevue/button'
</script>
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold">Activities</h1>
<Button label="Upload .FIT" icon="pi pi-upload" />
</div>
<Card>
<template #content>
<p class="text-surface-400">Activity list with filters coming soon</p>
</template>
</Card>
</div>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import Card from 'primevue/card'
const route = useRoute()
</script>
<template>
<div>
<h1 class="text-2xl font-semibold mb-6">Activity Detail</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<template #title>Power / HR Chart</template>
<template #content>
<p class="text-surface-400">ECharts coming soon</p>
</template>
</Card>
<Card>
<template #title>Route Map</template>
<template #content>
<p class="text-surface-400">Leaflet map coming soon</p>
</template>
</Card>
<Card>
<template #title>Metrics</template>
<template #content>
<p class="text-surface-400">NP, IF, TSS, zones coming soon</p>
</template>
</Card>
<Card>
<template #title>Intervals</template>
<template #content>
<p class="text-surface-400">Detected intervals table coming soon</p>
</template>
</Card>
</div>
<p class="text-surface-500 text-sm mt-4">ID: {{ route.params.id }}</p>
</div>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import Card from 'primevue/card'
</script>
<template>
<div>
<h1 class="text-2xl font-semibold mb-6">Dashboard</h1>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<template #title>Current Form</template>
<template #content>
<p class="text-surface-400">CTL / ATL / TSB coming soon</p>
</template>
</Card>
<Card>
<template #title>Weekly Load</template>
<template #content>
<p class="text-surface-400">Weekly TSS summary coming soon</p>
</template>
</Card>
<Card>
<template #title>Recent Rides</template>
<template #content>
<p class="text-surface-400">Last 5 activities coming soon</p>
</template>
</Card>
</div>
</div>
</template>

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useAuthStore } from '../stores/auth'
import Card from 'primevue/card'
import ProgressSpinner from 'primevue/progressspinner'
const auth = useAuthStore()
const loading = ref(false)
const error = ref('')
// Telegram Login Widget callback — must be global
;(window as any).onTelegramAuth = async (user: TelegramLoginData) => {
loading.value = true
error.value = ''
try {
await auth.loginWithTelegram(user)
} catch (e: any) {
error.value = e.response?.data?.detail || 'Authorization failed'
loading.value = false
}
}
onMounted(async () => {
// Check if running inside Telegram WebApp
const webapp = window.Telegram?.WebApp
if (webapp?.initData) {
loading.value = true
webapp.ready()
webapp.expand()
try {
await auth.loginWithWebApp(webapp.initData)
} catch (e: any) {
error.value = e.response?.data?.detail || 'WebApp authorization failed'
loading.value = false
}
return
}
// Render Telegram Login Widget for browser users
const container = document.getElementById('telegram-login-container')
if (container) {
const script = document.createElement('script')
script.src = 'https://telegram.org/js/telegram-widget.js?22'
script.setAttribute('data-telegram-login', import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '')
script.setAttribute('data-size', 'large')
script.setAttribute('data-radius', '8')
script.setAttribute('data-onauth', 'onTelegramAuth(user)')
script.setAttribute('data-request-access', 'write')
script.async = true
container.appendChild(script)
}
})
</script>
<template>
<div class="flex items-center justify-center min-h-[80vh]">
<Card class="w-full max-w-md">
<template #title>
<div class="text-center">
<span class="text-primary text-3xl font-bold">VeloBrain</span>
<p class="text-surface-400 text-sm mt-2">AI-Powered Cycling Training Platform</p>
</div>
</template>
<template #content>
<div class="flex flex-col items-center gap-4">
<div v-if="loading" class="flex flex-col items-center gap-3">
<ProgressSpinner style="width: 50px; height: 50px" />
<p class="text-surface-400 text-sm">Signing in via Telegram...</p>
</div>
<div v-else>
<p v-if="error" class="text-red-400 text-sm mb-4 text-center">{{ error }}</p>
<div id="telegram-login-container" class="flex justify-center"></div>
</div>
</div>
</template>
</Card>
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import Card from 'primevue/card'
</script>
<template>
<div>
<h1 class="text-2xl font-semibold mb-6">Settings</h1>
<Card>
<template #content>
<p class="text-surface-400">FTP, weight, zones, goals coming soon</p>
</template>
</Card>
</div>
</template>