init
This commit is contained in:
31
frontend/src/App.vue
Normal file
31
frontend/src/App.vue
Normal 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>
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal 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 |
28
frontend/src/composables/useApi.ts
Normal file
28
frontend/src/composables/useApi.ts
Normal 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
26
frontend/src/main.ts
Normal 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
52
frontend/src/router.ts
Normal 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
|
||||
32
frontend/src/stores/activities.ts
Normal file
32
frontend/src/stores/activities.ts
Normal 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 }
|
||||
})
|
||||
45
frontend/src/stores/auth.ts
Normal file
45
frontend/src/stores/auth.ts
Normal 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 }
|
||||
})
|
||||
21
frontend/src/stores/rider.ts
Normal file
21
frontend/src/stores/rider.ts
Normal 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
3
frontend/src/style.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "tailwindcss-primeui";
|
||||
@import "primeicons/primeicons.css";
|
||||
50
frontend/src/types/models.ts
Normal file
50
frontend/src/types/models.ts
Normal 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
31
frontend/src/types/telegram.d.ts
vendored
Normal 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
|
||||
}
|
||||
18
frontend/src/views/ActivitiesView.vue
Normal file
18
frontend/src/views/ActivitiesView.vue
Normal 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>
|
||||
39
frontend/src/views/ActivityDetailView.vue
Normal file
39
frontend/src/views/ActivityDetailView.vue
Normal 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>
|
||||
29
frontend/src/views/DashboardView.vue
Normal file
29
frontend/src/views/DashboardView.vue
Normal 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>
|
||||
78
frontend/src/views/LoginView.vue
Normal file
78
frontend/src/views/LoginView.vue
Normal 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>
|
||||
14
frontend/src/views/SettingsView.vue
Normal file
14
frontend/src/views/SettingsView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user