feat(skilltree): connect web app to backend API

- Add API client for backend communication
- Add skills and activities API modules
- Add auth store with Mana Core Auth integration
- Update skills store to use API when authenticated
- Keep IndexedDB as offline fallback
- Add hooks.server.ts for runtime env injection

https://claude.ai/code/session_015XCsTDS9aLZ64Zin4HU6ex
This commit is contained in:
Claude 2026-01-29 11:05:32 +00:00
parent 7a0b26eb3d
commit 076e5518cc
No known key found for this signature in database
8 changed files with 636 additions and 59 deletions

View file

@ -0,0 +1,12 @@
# SkillTree Web App - Production Environment Variables
# Copy this file to .env.production and fill in the values
# =============================================================================
# REQUIRED
# =============================================================================
# Backend API URL
PUBLIC_BACKEND_URL=https://skilltree-api.mana.how
# Mana Core Auth URL for authentication
PUBLIC_MANA_CORE_AUTH_URL=https://auth.mana.how

View file

@ -25,6 +25,7 @@
"vite": "^6.0.0"
},
"dependencies": {
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-utils": "workspace:*",

View file

@ -0,0 +1,24 @@
/**
* Server Hooks for SvelteKit
* - Injects runtime environment variables for client-side use
* - Auth is handled client-side via Mana Core Auth
*/
import type { Handle } from '@sveltejs/kit';
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
const PUBLIC_BACKEND_URL_CLIENT =
process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || '';
export const handle: Handle = async ({ event, resolve }) => {
return resolve(event, {
transformPageChunk: ({ html }) => {
const envScript = `<script>
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${PUBLIC_MANA_CORE_AUTH_URL_CLIENT}";
window.__PUBLIC_BACKEND_URL__ = "${PUBLIC_BACKEND_URL_CLIENT}";
</script>`;
return html.replace('<head>', `<head>${envScript}`);
},
});
};

View file

@ -0,0 +1,23 @@
import { apiClient } from './client';
import type { Activity } from '$lib/types';
interface ActivitiesResponse {
activities: Activity[];
}
export async function getActivities(skillId?: string, limit?: number): Promise<Activity[]> {
const params = new URLSearchParams();
if (skillId) params.append('skillId', skillId);
if (limit) params.append('limit', String(limit));
const queryString = params.toString() ? `?${params.toString()}` : '';
const response = await apiClient.get<ActivitiesResponse>(`/api/v1/activities${queryString}`);
return response.activities;
}
export async function getRecentActivities(limit = 10): Promise<Activity[]> {
return getActivities(undefined, limit);
}
export async function getSkillActivities(skillId: string): Promise<Activity[]> {
return getActivities(skillId);
}

View file

@ -0,0 +1,98 @@
import { browser } from '$app/environment';
import { PUBLIC_BACKEND_URL } from '$env/static/public';
interface ApiOptions {
method?: string;
body?: unknown;
headers?: Record<string, string>;
}
interface ApiError {
message: string;
statusCode: number;
}
/**
* Get the backend URL, preferring runtime-injected value in browser
* This allows Docker to inject PUBLIC_BACKEND_URL_CLIENT at runtime
*/
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
const runtimeUrl = (window as Window & { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
if (runtimeUrl) {
return runtimeUrl;
}
}
return PUBLIC_BACKEND_URL || 'http://localhost:3024';
}
class ApiClient {
private accessToken: string | null = null;
private get baseUrl(): string {
return getBackendUrl();
}
setAccessToken(token: string | null) {
this.accessToken = token;
}
getAccessToken(): string | null {
return this.accessToken;
}
async fetch<T>(endpoint: string, options: ApiOptions = {}): Promise<T> {
const { method = 'GET', body, headers = {} } = options;
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json',
...headers,
};
if (this.accessToken) {
requestHeaders['Authorization'] = `Bearer ${this.accessToken}`;
}
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method,
headers: requestHeaders,
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
let errorMessage = 'An error occurred';
try {
const errorData = (await response.json()) as ApiError;
errorMessage = errorData.message || errorMessage;
} catch {
errorMessage = response.statusText || errorMessage;
}
throw new Error(errorMessage);
}
if (response.status === 204) {
return {} as T;
}
return response.json() as Promise<T>;
}
get<T>(endpoint: string, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'GET', headers });
}
post<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'POST', body, headers });
}
put<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'PUT', body, headers });
}
delete<T>(endpoint: string, headers?: Record<string, string>): Promise<T> {
return this.fetch<T>(endpoint, { method: 'DELETE', headers });
}
}
export const apiClient = new ApiClient();

View file

@ -0,0 +1,80 @@
import { apiClient } from './client';
import type { Skill, Activity, UserStats, SkillBranch } from '$lib/types';
interface CreateSkillDto {
name: string;
description?: string;
branch: SkillBranch;
parentId?: string;
icon?: string;
color?: string;
}
interface UpdateSkillDto {
name?: string;
description?: string;
branch?: SkillBranch;
parentId?: string | null;
icon?: string;
color?: string | null;
}
interface AddXpDto {
xp: number;
description: string;
duration?: number;
}
interface AddXpResponse {
skill: Skill;
activity: Activity;
leveledUp: boolean;
previousLevel: number;
newLevel: number;
}
interface SkillsResponse {
skills: Skill[];
}
interface SkillResponse {
skill: Skill;
}
interface StatsResponse {
stats: UserStats;
}
export async function getSkills(branch?: SkillBranch): Promise<Skill[]> {
const queryString = branch ? `?branch=${branch}` : '';
const response = await apiClient.get<SkillsResponse>(`/api/v1/skills${queryString}`);
return response.skills;
}
export async function getSkill(id: string): Promise<Skill> {
const response = await apiClient.get<SkillResponse>(`/api/v1/skills/${id}`);
return response.skill;
}
export async function createSkill(data: CreateSkillDto): Promise<Skill> {
const response = await apiClient.post<SkillResponse>('/api/v1/skills', data);
return response.skill;
}
export async function updateSkill(id: string, data: UpdateSkillDto): Promise<Skill> {
const response = await apiClient.put<SkillResponse>(`/api/v1/skills/${id}`, data);
return response.skill;
}
export async function deleteSkill(id: string): Promise<void> {
await apiClient.delete(`/api/v1/skills/${id}`);
}
export async function addXp(skillId: string, data: AddXpDto): Promise<AddXpResponse> {
return await apiClient.post<AddXpResponse>(`/api/v1/skills/${skillId}/xp`, data);
}
export async function getStats(): Promise<UserStats> {
const response = await apiClient.get<StatsResponse>('/api/v1/skills/stats');
return response.stats;
}

View file

@ -0,0 +1,212 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Uses Mana Core Auth
*/
import { browser } from '$app/environment';
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
import { apiClient } from '$lib/api/client';
const DEV_AUTH_URL = 'http://localhost:3001';
const DEV_BACKEND_URL = 'http://localhost:3024';
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
if (injectedUrl) return injectedUrl;
return import.meta.env.DEV ? DEV_AUTH_URL : '';
}
return process.env.PUBLIC_MANA_CORE_AUTH_URL || DEV_AUTH_URL;
}
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
if (injectedUrl) return injectedUrl;
return import.meta.env.DEV ? DEV_BACKEND_URL : '';
}
return process.env.PUBLIC_BACKEND_URL || DEV_BACKEND_URL;
}
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getBackendUrl(),
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}
return _authService;
}
function getTokenManager() {
if (!browser) return null;
getAuthService();
return _tokenManager;
}
let user = $state<UserData | null>(null);
let loading = $state(true);
let initialized = $state(false);
export const authStore = {
get user() {
return user;
},
get loading() {
return loading;
},
get isAuthenticated() {
return !!user;
},
get initialized() {
return initialized;
},
async initialize() {
if (initialized) return;
const authService = getAuthService();
if (!authService) {
initialized = true;
loading = false;
return;
}
loading = true;
try {
const authenticated = await authService.isAuthenticated();
if (authenticated) {
const userData = await authService.getUserFromToken();
user = userData;
const token = await authService.getAppToken();
if (token) {
apiClient.setAccessToken(token);
}
}
initialized = true;
} catch (error) {
console.error('Failed to initialize auth:', error);
user = null;
} finally {
loading = false;
}
},
async signIn(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.signIn(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Login failed' };
}
const userData = await authService.getUserFromToken();
user = userData;
const token = await authService.getAppToken();
if (token) {
apiClient.setAccessToken(token);
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
async signUp(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
try {
const sourceAppUrl = browser ? window.location.origin : undefined;
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
if (!result.success) {
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
}
if (result.needsVerification) {
return { success: true, needsVerification: true };
}
const signInResult = await this.signIn(email, password);
return { ...signInResult, needsVerification: false };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage, needsVerification: false };
}
},
async signOut() {
const authService = getAuthService();
if (!authService) {
user = null;
apiClient.setAccessToken(null);
return;
}
try {
await authService.signOut();
user = null;
apiClient.setAccessToken(null);
} catch (error) {
console.error('Sign out error:', error);
user = null;
apiClient.setAccessToken(null);
}
},
async resetPassword(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.forgotPassword(email);
if (!result.success) {
return { success: false, error: result.error || 'Password reset failed' };
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
async getAccessToken() {
const authService = getAuthService();
if (!authService) {
return null;
}
return await authService.getAppToken();
},
async getValidToken(): Promise<string | null> {
const tokenManager = getTokenManager();
if (!tokenManager) {
return null;
}
return await tokenManager.getValidToken();
},
};

View file

@ -1,11 +1,9 @@
import type { Skill, Activity, UserStats, SkillBranch } from '$lib/types';
import {
calculateLevel,
createDefaultSkill,
createActivity,
BRANCH_INFO,
} from '$lib/types';
import { calculateLevel, createDefaultSkill, createActivity, BRANCH_INFO } from '$lib/types';
import * as storage from '$lib/services/storage';
import * as skillsApi from '$lib/api/skills';
import * as activitiesApi from '$lib/api/activities';
import { authStore } from './auth.svelte';
// Reactive state using Svelte 5 runes
let skills = $state<Skill[]>([]);
@ -19,6 +17,7 @@ let userStats = $state<UserStats>({
});
let isLoading = $state(true);
let initialized = $state(false);
let useApi = $state(false);
// Derived values
const skillsByBranch = $derived(() => {
@ -42,13 +41,14 @@ const topSkills = $derived(() => {
});
const recentActivities = $derived(() => {
return [...activities].sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
).slice(0, 10);
return [...activities]
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 10);
});
const branchStats = $derived(() => {
const stats: Record<SkillBranch, { count: number; totalXp: number; avgLevel: number }> = {} as any;
const stats: Record<SkillBranch, { count: number; totalXp: number; avgLevel: number }> =
{} as Record<SkillBranch, { count: number; totalXp: number; avgLevel: number }>;
for (const branch of Object.keys(BRANCH_INFO) as SkillBranch[]) {
const branchSkills = skills.filter((s) => s.branch === branch);
stats[branch] = {
@ -69,81 +69,171 @@ async function initialize() {
isLoading = true;
try {
const [loadedSkills, loadedActivities, loadedStats] = await Promise.all([
storage.getAllSkills(),
storage.getAllActivities(),
storage.getUserStats(),
]);
skills = loadedSkills;
activities = loadedActivities;
userStats = loadedStats;
// Check if user is authenticated
if (authStore.isAuthenticated) {
useApi = true;
const [loadedSkills, loadedActivities, loadedStats] = await Promise.all([
skillsApi.getSkills(),
activitiesApi.getRecentActivities(50),
skillsApi.getStats(),
]);
skills = loadedSkills;
activities = loadedActivities;
userStats = loadedStats;
} else {
// Fallback to IndexedDB for offline/unauthenticated use
useApi = false;
const [loadedSkills, loadedActivities, loadedStats] = await Promise.all([
storage.getAllSkills(),
storage.getAllActivities(),
storage.getUserStats(),
]);
skills = loadedSkills;
activities = loadedActivities;
userStats = loadedStats;
}
initialized = true;
} catch (error) {
console.error('Failed to initialize skills store:', error);
// On error, try IndexedDB as fallback
if (useApi) {
try {
useApi = false;
const [loadedSkills, loadedActivities, loadedStats] = await Promise.all([
storage.getAllSkills(),
storage.getAllActivities(),
storage.getUserStats(),
]);
skills = loadedSkills;
activities = loadedActivities;
userStats = loadedStats;
} catch (fallbackError) {
console.error('Fallback to IndexedDB also failed:', fallbackError);
}
}
} finally {
isLoading = false;
}
}
async function addSkill(data: Partial<Skill>): Promise<Skill> {
const skill = createDefaultSkill(data);
await storage.saveSkill(skill);
skills = [...skills, skill];
await updateStats();
return skill;
if (useApi && authStore.isAuthenticated) {
const skill = await skillsApi.createSkill({
name: data.name || '',
description: data.description,
branch: data.branch || 'custom',
parentId: data.parentId ?? undefined,
icon: data.icon,
color: data.color ?? undefined,
});
skills = [...skills, skill];
await updateStats();
return skill;
} else {
const skill = createDefaultSkill(data);
await storage.saveSkill(skill);
skills = [...skills, skill];
await updateStats();
return skill;
}
}
async function updateSkill(id: string, updates: Partial<Skill>): Promise<void> {
const index = skills.findIndex((s) => s.id === id);
if (index === -1) return;
const updatedSkill = { ...skills[index], ...updates, updatedAt: new Date().toISOString() };
await storage.saveSkill(updatedSkill);
skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)];
if (useApi && authStore.isAuthenticated) {
const skill = await skillsApi.updateSkill(id, {
name: updates.name,
description: updates.description,
branch: updates.branch,
parentId: updates.parentId,
icon: updates.icon,
color: updates.color,
});
skills = [...skills.slice(0, index), skill, ...skills.slice(index + 1)];
} else {
const updatedSkill = { ...skills[index], ...updates, updatedAt: new Date().toISOString() };
await storage.saveSkill(updatedSkill);
skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)];
}
await updateStats();
}
async function deleteSkill(id: string): Promise<void> {
await storage.deleteSkill(id);
if (useApi && authStore.isAuthenticated) {
await skillsApi.deleteSkill(id);
} else {
await storage.deleteSkill(id);
}
skills = skills.filter((s) => s.id !== id);
activities = activities.filter((a) => a.skillId !== id);
await updateStats();
}
async function addXp(skillId: string, xp: number, description: string, duration?: number): Promise<{ leveledUp: boolean; newLevel: number }> {
async function addXp(
skillId: string,
xp: number,
description: string,
duration?: number
): Promise<{ leveledUp: boolean; newLevel: number }> {
const index = skills.findIndex((s) => s.id === skillId);
if (index === -1) return { leveledUp: false, newLevel: 0 };
const skill = skills[index];
const newTotalXp = skill.totalXp + xp;
const newCurrentXp = skill.currentXp + xp;
const newLevel = calculateLevel(newTotalXp);
const leveledUp = newLevel > skill.level;
if (useApi && authStore.isAuthenticated) {
const result = await skillsApi.addXp(skillId, { xp, description, duration });
skills = [...skills.slice(0, index), result.skill, ...skills.slice(index + 1)];
activities = [...activities, result.activity];
await updateStats();
return { leveledUp: result.leveledUp, newLevel: result.newLevel };
} else {
const skill = skills[index];
const newTotalXp = skill.totalXp + xp;
const newCurrentXp = skill.currentXp + xp;
const newLevel = calculateLevel(newTotalXp);
const leveledUp = newLevel > skill.level;
const updatedSkill: Skill = {
...skill,
totalXp: newTotalXp,
currentXp: newCurrentXp,
level: newLevel,
updatedAt: new Date().toISOString(),
};
const updatedSkill: Skill = {
...skill,
totalXp: newTotalXp,
currentXp: newCurrentXp,
level: newLevel,
updatedAt: new Date().toISOString(),
};
const activity = createActivity(skillId, xp, description, duration);
const activity = createActivity(skillId, xp, description, duration);
await Promise.all([
storage.saveSkill(updatedSkill),
storage.saveActivity(activity),
]);
await Promise.all([storage.saveSkill(updatedSkill), storage.saveActivity(activity)]);
skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)];
activities = [...activities, activity];
await updateStats();
skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)];
activities = [...activities, activity];
await updateStats();
return { leveledUp, newLevel };
return { leveledUp, newLevel };
}
}
async function updateStats(): Promise<void> {
userStats = await storage.recalculateStats();
if (useApi && authStore.isAuthenticated) {
try {
userStats = await skillsApi.getStats();
} catch {
// Calculate locally as fallback
userStats = calculateLocalStats();
}
} else {
userStats = await storage.recalculateStats();
}
}
function calculateLocalStats(): UserStats {
return {
totalXp: skills.reduce((sum, s) => sum + s.totalXp, 0),
totalSkills: skills.length,
highestLevel: skills.reduce((max, s) => Math.max(max, s.level), 0),
streakDays: 0,
lastActivityDate: activities.length > 0 ? activities[activities.length - 1].timestamp : null,
};
}
function getSkill(id: string): Skill | undefined {
@ -154,19 +244,56 @@ function getSkillActivities(skillId: string): Activity[] {
return activities.filter((a) => a.skillId === skillId);
}
// Reinitialize when auth state changes
async function reinitialize() {
initialized = false;
skills = [];
activities = [];
userStats = {
totalXp: 0,
totalSkills: 0,
highestLevel: 0,
streakDays: 0,
lastActivityDate: null,
};
await initialize();
}
// Export store as object with getters for reactive access
export const skillStore = {
get skills() { return skills; },
get activities() { return activities; },
get userStats() { return userStats; },
get isLoading() { return isLoading; },
get initialized() { return initialized; },
get skillsByBranch() { return skillsByBranch; },
get topSkills() { return topSkills; },
get recentActivities() { return recentActivities; },
get branchStats() { return branchStats; },
get skills() {
return skills;
},
get activities() {
return activities;
},
get userStats() {
return userStats;
},
get isLoading() {
return isLoading;
},
get initialized() {
return initialized;
},
get skillsByBranch() {
return skillsByBranch;
},
get topSkills() {
return topSkills;
},
get recentActivities() {
return recentActivities;
},
get branchStats() {
return branchStats;
},
get useApi() {
return useApi;
},
initialize,
reinitialize,
addSkill,
updateSkill,
deleteSkill,