mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
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:
parent
7a0b26eb3d
commit
076e5518cc
8 changed files with 636 additions and 59 deletions
12
apps/skilltree/apps/web/.env.production.example
Normal file
12
apps/skilltree/apps/web/.env.production.example
Normal 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
|
||||
|
|
@ -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:*",
|
||||
|
|
|
|||
24
apps/skilltree/apps/web/src/hooks.server.ts
Normal file
24
apps/skilltree/apps/web/src/hooks.server.ts
Normal 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}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
23
apps/skilltree/apps/web/src/lib/api/activities.ts
Normal file
23
apps/skilltree/apps/web/src/lib/api/activities.ts
Normal 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);
|
||||
}
|
||||
98
apps/skilltree/apps/web/src/lib/api/client.ts
Normal file
98
apps/skilltree/apps/web/src/lib/api/client.ts
Normal 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();
|
||||
80
apps/skilltree/apps/web/src/lib/api/skills.ts
Normal file
80
apps/skilltree/apps/web/src/lib/api/skills.ts
Normal 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;
|
||||
}
|
||||
212
apps/skilltree/apps/web/src/lib/stores/auth.svelte.ts
Normal file
212
apps/skilltree/apps/web/src/lib/stores/auth.svelte.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue