mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 06:49:40 +02:00
feat: major update with network graphs, themes, todo extensions, and more
## New Features ### Network Graph Visualization (Contacts, Calendar, Todo) - D3.js force simulation for physics-based layout - Zoom & pan with mouse/touchpad - Keyboard shortcuts: +/- zoom, 0 reset, Esc deselect, / search, F focus - Filtering by tags, company/location/project, connection strength - Shared components in @manacore/shared-ui ### Central Tags API (mana-core-auth) - CRUD endpoints for tags - Schema: tags table with userId, name, color, app - Shared tag components in @manacore/shared-ui ### Custom Themes System - Theme editor with live preview and color picker - Community theme gallery - Theme sharing (public, unlisted, private) - Backend API in mana-core-auth ### Todo App Extensions - Glass-pill design for task input and items - Settings page with 20+ preferences - Task edit modal with inline editing - Statistics page with visualizations - PWA support with offline capabilities - Multiple kanban boards ### Contacts App Features - Duplicate detection - Photo upload - Batch operations - Enhanced favorites page with multiple view modes - Alphabet view improvements - Search modal ### Help System - @manacore/shared-help-content - @manacore/shared-help-ui - @manacore/shared-help-types ### Other Features - Themes page for all apps - Referral system frontend - CommandBar (global search) - Skeleton loaders - Settings page improvements ## Bug Fixes - Network graph simulation initialization - Database schema TEXT for user_id columns (Better Auth compatibility) - Various styling fixes ## Documentation - Daily report for 2025-12-10 - CI/CD deployment guide 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e84371aa94
commit
ee42b6cc76
381 changed files with 39284 additions and 6275 deletions
|
|
@ -8,6 +8,10 @@ export const THEME_VARIANTS: readonly ThemeVariant[] = [
|
|||
'nature',
|
||||
'stone',
|
||||
'ocean',
|
||||
'sunset',
|
||||
'midnight',
|
||||
'rose',
|
||||
'lavender',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
|
@ -194,6 +198,178 @@ const oceanDark: ThemeColors = {
|
|||
ring: '199 98% 48%',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Extended Themes: Sunset, Midnight, Rose, Lavender
|
||||
// ============================================================================
|
||||
|
||||
const sunsetLight: ThemeColors = {
|
||||
primary: '15 90% 55%', // Coral/Orange
|
||||
primaryForeground: '0 0% 100%',
|
||||
secondary: '25 100% 60%', // Warm orange
|
||||
secondaryForeground: '0 0% 0%',
|
||||
background: '30 50% 97%', // Warm cream
|
||||
foreground: '15 50% 20%', // Dark warm brown
|
||||
surface: '0 0% 100%',
|
||||
surfaceHover: '30 40% 95%',
|
||||
surfaceElevated: '0 0% 100%',
|
||||
muted: '30 30% 93%',
|
||||
mutedForeground: '15 20% 45%',
|
||||
border: '30 25% 88%',
|
||||
borderStrong: '30 30% 75%',
|
||||
error: '0 72% 55%',
|
||||
success: '145 63% 42%',
|
||||
warning: '36 100% 50%',
|
||||
input: '0 0% 100%',
|
||||
ring: '15 90% 55%',
|
||||
};
|
||||
|
||||
const sunsetDark: ThemeColors = {
|
||||
primary: '15 85% 58%', // Brighter coral in dark
|
||||
primaryForeground: '0 0% 0%',
|
||||
secondary: '25 60% 35%',
|
||||
secondaryForeground: '0 0% 100%',
|
||||
background: '15 20% 8%', // Dark with warm tint
|
||||
foreground: '0 0% 100%',
|
||||
surface: '15 15% 12%',
|
||||
surfaceHover: '15 15% 16%',
|
||||
surfaceElevated: '15 15% 14%',
|
||||
muted: '15 12% 20%',
|
||||
mutedForeground: '15 10% 60%',
|
||||
border: '15 12% 25%',
|
||||
borderStrong: '15 12% 35%',
|
||||
error: '0 72% 55%',
|
||||
success: '145 63% 49%',
|
||||
warning: '48 100% 50%',
|
||||
input: '15 20% 14%',
|
||||
ring: '15 85% 58%',
|
||||
};
|
||||
|
||||
const midnightLight: ThemeColors = {
|
||||
primary: '260 70% 55%', // Deep purple/violet
|
||||
primaryForeground: '0 0% 100%',
|
||||
secondary: '270 60% 70%', // Lighter purple
|
||||
secondaryForeground: '0 0% 0%',
|
||||
background: '260 30% 97%', // Very light purple tint
|
||||
foreground: '260 50% 20%', // Dark purple text
|
||||
surface: '0 0% 100%',
|
||||
surfaceHover: '260 25% 95%',
|
||||
surfaceElevated: '0 0% 100%',
|
||||
muted: '260 20% 93%',
|
||||
mutedForeground: '260 20% 45%',
|
||||
border: '260 20% 88%',
|
||||
borderStrong: '260 25% 75%',
|
||||
error: '0 72% 55%',
|
||||
success: '145 63% 42%',
|
||||
warning: '36 100% 50%',
|
||||
input: '0 0% 100%',
|
||||
ring: '260 70% 55%',
|
||||
};
|
||||
|
||||
const midnightDark: ThemeColors = {
|
||||
primary: '260 65% 60%', // Brighter purple in dark
|
||||
primaryForeground: '0 0% 100%',
|
||||
secondary: '270 40% 35%',
|
||||
secondaryForeground: '0 0% 100%',
|
||||
background: '260 25% 7%', // Deep dark purple
|
||||
foreground: '0 0% 100%',
|
||||
surface: '260 20% 11%',
|
||||
surfaceHover: '260 20% 15%',
|
||||
surfaceElevated: '260 20% 13%',
|
||||
muted: '260 15% 19%',
|
||||
mutedForeground: '260 12% 60%',
|
||||
border: '260 15% 24%',
|
||||
borderStrong: '260 15% 34%',
|
||||
error: '0 72% 55%',
|
||||
success: '145 63% 49%',
|
||||
warning: '48 100% 50%',
|
||||
input: '260 25% 14%',
|
||||
ring: '260 65% 60%',
|
||||
};
|
||||
|
||||
const roseLight: ThemeColors = {
|
||||
primary: '340 80% 55%', // Pink/Magenta
|
||||
primaryForeground: '0 0% 100%',
|
||||
secondary: '350 70% 70%', // Lighter pink
|
||||
secondaryForeground: '0 0% 0%',
|
||||
background: '340 40% 97%', // Very light pink tint
|
||||
foreground: '340 50% 20%', // Dark rose text
|
||||
surface: '0 0% 100%',
|
||||
surfaceHover: '340 30% 95%',
|
||||
surfaceElevated: '0 0% 100%',
|
||||
muted: '340 25% 93%',
|
||||
mutedForeground: '340 20% 45%',
|
||||
border: '340 25% 88%',
|
||||
borderStrong: '340 30% 75%',
|
||||
error: '0 72% 55%',
|
||||
success: '145 63% 42%',
|
||||
warning: '36 100% 50%',
|
||||
input: '0 0% 100%',
|
||||
ring: '340 80% 55%',
|
||||
};
|
||||
|
||||
const roseDark: ThemeColors = {
|
||||
primary: '340 75% 60%', // Brighter pink in dark
|
||||
primaryForeground: '0 0% 100%',
|
||||
secondary: '350 45% 35%',
|
||||
secondaryForeground: '0 0% 100%',
|
||||
background: '340 20% 8%', // Dark with pink tint
|
||||
foreground: '0 0% 100%',
|
||||
surface: '340 15% 12%',
|
||||
surfaceHover: '340 15% 16%',
|
||||
surfaceElevated: '340 15% 14%',
|
||||
muted: '340 12% 20%',
|
||||
mutedForeground: '340 10% 60%',
|
||||
border: '340 12% 25%',
|
||||
borderStrong: '340 12% 35%',
|
||||
error: '0 72% 55%',
|
||||
success: '145 63% 49%',
|
||||
warning: '48 100% 50%',
|
||||
input: '340 20% 14%',
|
||||
ring: '340 75% 60%',
|
||||
};
|
||||
|
||||
const lavenderLight: ThemeColors = {
|
||||
primary: '270 60% 60%', // Lavender/Light purple
|
||||
primaryForeground: '0 0% 100%',
|
||||
secondary: '280 50% 75%', // Softer purple
|
||||
secondaryForeground: '0 0% 0%',
|
||||
background: '270 35% 97%', // Very light lavender
|
||||
foreground: '270 40% 22%', // Dark lavender text
|
||||
surface: '0 0% 100%',
|
||||
surfaceHover: '270 25% 95%',
|
||||
surfaceElevated: '0 0% 100%',
|
||||
muted: '270 20% 93%',
|
||||
mutedForeground: '270 18% 45%',
|
||||
border: '270 20% 88%',
|
||||
borderStrong: '270 25% 78%',
|
||||
error: '0 72% 55%',
|
||||
success: '145 63% 42%',
|
||||
warning: '36 100% 50%',
|
||||
input: '0 0% 100%',
|
||||
ring: '270 60% 60%',
|
||||
};
|
||||
|
||||
const lavenderDark: ThemeColors = {
|
||||
primary: '270 55% 65%', // Brighter lavender in dark
|
||||
primaryForeground: '0 0% 0%',
|
||||
secondary: '280 35% 38%',
|
||||
secondaryForeground: '0 0% 100%',
|
||||
background: '270 20% 8%', // Dark with lavender tint
|
||||
foreground: '0 0% 100%',
|
||||
surface: '270 15% 12%',
|
||||
surfaceHover: '270 15% 16%',
|
||||
surfaceElevated: '270 15% 14%',
|
||||
muted: '270 12% 20%',
|
||||
mutedForeground: '270 10% 60%',
|
||||
border: '270 12% 25%',
|
||||
borderStrong: '270 12% 35%',
|
||||
error: '0 72% 55%',
|
||||
success: '145 63% 49%',
|
||||
warning: '48 100% 50%',
|
||||
input: '270 20% 14%',
|
||||
ring: '270 55% 65%',
|
||||
};
|
||||
|
||||
/**
|
||||
* Complete theme variant definitions
|
||||
*/
|
||||
|
|
@ -234,6 +410,43 @@ export const THEME_DEFINITIONS: Record<ThemeVariant, ThemeVariantDefinition> = {
|
|||
light: oceanLight,
|
||||
dark: oceanDark,
|
||||
},
|
||||
// Extended themes (not in PillNav by default, can be pinned)
|
||||
sunset: {
|
||||
name: 'sunset',
|
||||
label: 'Sunset',
|
||||
emoji: '🌅',
|
||||
icon: 'sun',
|
||||
hue: 15,
|
||||
light: sunsetLight,
|
||||
dark: sunsetDark,
|
||||
},
|
||||
midnight: {
|
||||
name: 'midnight',
|
||||
label: 'Midnight',
|
||||
emoji: '🌙',
|
||||
icon: 'moon',
|
||||
hue: 260,
|
||||
light: midnightLight,
|
||||
dark: midnightDark,
|
||||
},
|
||||
rose: {
|
||||
name: 'rose',
|
||||
label: 'Rose',
|
||||
emoji: '🌹',
|
||||
icon: 'flower',
|
||||
hue: 340,
|
||||
light: roseLight,
|
||||
dark: roseDark,
|
||||
},
|
||||
lavender: {
|
||||
name: 'lavender',
|
||||
label: 'Lavender',
|
||||
emoji: '💜',
|
||||
icon: 'sparkle',
|
||||
hue: 270,
|
||||
light: lavenderLight,
|
||||
dark: lavenderDark,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
506
packages/shared-theme/src/custom-themes-store.svelte.ts
Normal file
506
packages/shared-theme/src/custom-themes-store.svelte.ts
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
import type {
|
||||
CustomTheme,
|
||||
CommunityTheme,
|
||||
CreateCustomThemeInput,
|
||||
UpdateCustomThemeInput,
|
||||
PublishThemeInput,
|
||||
CommunityThemeQuery,
|
||||
PaginatedCommunityThemes,
|
||||
CustomThemesStore,
|
||||
CustomThemesStoreConfig,
|
||||
ThemeColors,
|
||||
EffectiveMode,
|
||||
} from './types';
|
||||
import { isBrowser } from './utils';
|
||||
|
||||
/**
|
||||
* Apply a custom theme's colors to the document as CSS variables
|
||||
*/
|
||||
function applyCustomThemeToDocument(
|
||||
colors: ThemeColors,
|
||||
effectiveMode: EffectiveMode = 'light'
|
||||
): void {
|
||||
if (!isBrowser()) return;
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
// Apply all color variables
|
||||
Object.entries(colors).forEach(([key, value]) => {
|
||||
// Convert camelCase to kebab-case
|
||||
const cssVar = key.replace(/([A-Z])/g, '-$1').toLowerCase();
|
||||
root.style.setProperty(`--${cssVar}`, value);
|
||||
});
|
||||
|
||||
// Set mode class
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(effectiveMode);
|
||||
|
||||
// Mark as custom theme
|
||||
root.setAttribute('data-custom-theme', 'true');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear custom theme and revert to standard theme
|
||||
*/
|
||||
function clearCustomThemeFromDocument(): void {
|
||||
if (!isBrowser()) return;
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
// Remove custom theme marker
|
||||
root.removeAttribute('data-custom-theme');
|
||||
|
||||
// Clear inline styles (CSS vars will fall back to theme variant)
|
||||
root.style.cssText = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom themes store for managing user's custom themes and community themes
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createCustomThemesStore } from '@manacore/shared-theme';
|
||||
* import { authStore } from '$lib/stores/auth.svelte';
|
||||
*
|
||||
* export const customThemesStore = createCustomThemesStore({
|
||||
* authUrl: import.meta.env.PUBLIC_AUTH_URL,
|
||||
* getAccessToken: () => authStore.getAccessToken(),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createCustomThemesStore(config: CustomThemesStoreConfig): CustomThemesStore {
|
||||
const { authUrl, getAccessToken } = config;
|
||||
|
||||
// State
|
||||
let customThemes = $state<CustomTheme[]>([]);
|
||||
let communityThemes = $state<CommunityTheme[]>([]);
|
||||
let favorites = $state<CommunityTheme[]>([]);
|
||||
let downloaded = $state<CommunityTheme[]>([]);
|
||||
let pagination = $state({ page: 1, totalPages: 1, total: 0 });
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Track currently applied custom theme
|
||||
let appliedThemeId = $state<string | null>(null);
|
||||
|
||||
/**
|
||||
* Make an authenticated API request
|
||||
*/
|
||||
async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = await getAccessToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const url = `${authUrl}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `Request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
// Handle 204 No Content
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a public API request (no auth required)
|
||||
*/
|
||||
async function publicApiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `${authUrl}${endpoint}`;
|
||||
const token = await getAccessToken();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// Add auth if available (for user-specific data like favorites)
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...headers,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `Request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ==================== Custom Theme Operations ====================
|
||||
|
||||
/**
|
||||
* Load user's custom themes
|
||||
*/
|
||||
async function loadCustomThemes(): Promise<void> {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
customThemes = await apiRequest<CustomTheme[]>('/api/v1/themes');
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to load themes';
|
||||
throw err;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new custom theme
|
||||
*/
|
||||
async function createTheme(input: CreateCustomThemeInput): Promise<CustomTheme> {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const theme = await apiRequest<CustomTheme>('/api/v1/themes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
customThemes = [...customThemes, theme];
|
||||
return theme;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to create theme';
|
||||
throw err;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing custom theme
|
||||
*/
|
||||
async function updateTheme(id: string, input: UpdateCustomThemeInput): Promise<CustomTheme> {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const theme = await apiRequest<CustomTheme>(`/api/v1/themes/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
customThemes = customThemes.map((t) => (t.id === id ? theme : t));
|
||||
return theme;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to update theme';
|
||||
throw err;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a custom theme
|
||||
*/
|
||||
async function deleteTheme(id: string): Promise<void> {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
await apiRequest(`/api/v1/themes/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
customThemes = customThemes.filter((t) => t.id !== id);
|
||||
|
||||
// Clear applied theme if it was the deleted one
|
||||
if (appliedThemeId === id) {
|
||||
clearCustomTheme();
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to delete theme';
|
||||
throw err;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a custom theme to the community
|
||||
*/
|
||||
async function publishTheme(id: string, input?: PublishThemeInput): Promise<CommunityTheme> {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const communityTheme = await apiRequest<CommunityTheme>(`/api/v1/themes/${id}/publish`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input || {}),
|
||||
});
|
||||
|
||||
// Update the custom theme's isPublished status
|
||||
customThemes = customThemes.map((t) => (t.id === id ? { ...t, isPublished: true } : t));
|
||||
|
||||
return communityTheme;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to publish theme';
|
||||
throw err;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Community Theme Operations ====================
|
||||
|
||||
/**
|
||||
* Browse community themes with filtering/sorting
|
||||
*/
|
||||
async function browseCommunity(query?: CommunityThemeQuery): Promise<void> {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.page) params.set('page', String(query.page));
|
||||
if (query?.limit) params.set('limit', String(query.limit));
|
||||
if (query?.sort) params.set('sort', query.sort);
|
||||
if (query?.search) params.set('search', query.search);
|
||||
if (query?.authorId) params.set('authorId', query.authorId);
|
||||
if (query?.featuredOnly) params.set('featuredOnly', 'true');
|
||||
if (query?.tags?.length) {
|
||||
query.tags.forEach((tag) => params.append('tags', tag));
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const endpoint = `/api/v1/community-themes${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const result = await publicApiRequest<PaginatedCommunityThemes>(endpoint);
|
||||
communityThemes = result.themes;
|
||||
pagination = {
|
||||
page: result.page,
|
||||
totalPages: result.totalPages,
|
||||
total: result.total,
|
||||
};
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to browse community themes';
|
||||
throw err;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download/install a community theme
|
||||
*/
|
||||
async function downloadTheme(id: string): Promise<CommunityTheme> {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const theme = await apiRequest<CommunityTheme>(`/api/v1/community-themes/${id}/download`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
// Update download status in community themes list
|
||||
communityThemes = communityThemes.map((t) =>
|
||||
t.id === id ? { ...t, isDownloaded: true, downloadCount: theme.downloadCount } : t
|
||||
);
|
||||
|
||||
// Add to downloaded list if not already there
|
||||
if (!downloaded.some((t) => t.id === id)) {
|
||||
downloaded = [...downloaded, theme];
|
||||
}
|
||||
|
||||
return theme;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to download theme';
|
||||
throw err;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate a community theme
|
||||
*/
|
||||
async function rateTheme(
|
||||
id: string,
|
||||
rating: number
|
||||
): Promise<{ averageRating: number; ratingCount: number }> {
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await apiRequest<{ averageRating: number; ratingCount: number }>(
|
||||
`/api/v1/community-themes/${id}/rate`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ rating }),
|
||||
}
|
||||
);
|
||||
|
||||
// Update rating in community themes list
|
||||
communityThemes = communityThemes.map((t) =>
|
||||
t.id === id
|
||||
? {
|
||||
...t,
|
||||
averageRating: result.averageRating,
|
||||
ratingCount: result.ratingCount,
|
||||
userRating: rating,
|
||||
}
|
||||
: t
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to rate theme';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle favorite status for a community theme
|
||||
*/
|
||||
async function toggleFavorite(id: string): Promise<{ isFavorited: boolean }> {
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await apiRequest<{ isFavorited: boolean }>(
|
||||
`/api/v1/community-themes/${id}/favorite`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
|
||||
// Update favorite status in community themes list
|
||||
communityThemes = communityThemes.map((t) =>
|
||||
t.id === id ? { ...t, isFavorited: result.isFavorited } : t
|
||||
);
|
||||
|
||||
// Update favorites list
|
||||
if (result.isFavorited) {
|
||||
const theme = communityThemes.find((t) => t.id === id);
|
||||
if (theme && !favorites.some((t) => t.id === id)) {
|
||||
favorites = [...favorites, { ...theme, isFavorited: true }];
|
||||
}
|
||||
} else {
|
||||
favorites = favorites.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to toggle favorite';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load user's favorite themes
|
||||
*/
|
||||
async function loadFavorites(): Promise<void> {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
favorites = await apiRequest<CommunityTheme[]>('/api/v1/community-themes/favorites');
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to load favorites';
|
||||
throw err;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load user's downloaded themes
|
||||
*/
|
||||
async function loadDownloaded(): Promise<void> {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
downloaded = await apiRequest<CommunityTheme[]>('/api/v1/community-themes/downloaded');
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to load downloaded themes';
|
||||
throw err;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Apply Theme ====================
|
||||
|
||||
/**
|
||||
* Apply a custom or community theme to the document
|
||||
*/
|
||||
function applyCustomTheme(theme: CustomTheme | CommunityTheme): void {
|
||||
// Determine effective mode from system or stored preference
|
||||
const effectiveMode: EffectiveMode = isBrowser()
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
: 'light';
|
||||
|
||||
const colors = effectiveMode === 'dark' ? theme.darkColors : theme.lightColors;
|
||||
applyCustomThemeToDocument(colors as ThemeColors, effectiveMode);
|
||||
appliedThemeId = theme.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the applied custom theme and revert to standard theme
|
||||
*/
|
||||
function clearCustomTheme(): void {
|
||||
clearCustomThemeFromDocument();
|
||||
appliedThemeId = null;
|
||||
}
|
||||
|
||||
return {
|
||||
get customThemes() {
|
||||
return customThemes;
|
||||
},
|
||||
get communityThemes() {
|
||||
return communityThemes;
|
||||
},
|
||||
get favorites() {
|
||||
return favorites;
|
||||
},
|
||||
get downloaded() {
|
||||
return downloaded;
|
||||
},
|
||||
get pagination() {
|
||||
return pagination;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
// Custom theme operations
|
||||
loadCustomThemes,
|
||||
createTheme,
|
||||
updateTheme,
|
||||
deleteTheme,
|
||||
publishTheme,
|
||||
|
||||
// Community theme operations
|
||||
browseCommunity,
|
||||
downloadTheme,
|
||||
rateTheme,
|
||||
toggleFavorite,
|
||||
loadFavorites,
|
||||
loadDownloaded,
|
||||
|
||||
// Apply theme
|
||||
applyCustomTheme,
|
||||
clearCustomTheme,
|
||||
};
|
||||
}
|
||||
|
|
@ -28,11 +28,29 @@ export type {
|
|||
StartPageConfig,
|
||||
WeekStartDay,
|
||||
GeneralSettings,
|
||||
// Custom & Community Themes Types
|
||||
ThemeColorsInput,
|
||||
CustomTheme,
|
||||
CreateCustomThemeInput,
|
||||
UpdateCustomThemeInput,
|
||||
CommunityTheme,
|
||||
CommunityThemeQuery,
|
||||
PaginatedCommunityThemes,
|
||||
PublishThemeInput,
|
||||
ThemeEditorState,
|
||||
CustomThemesStore,
|
||||
CustomThemesStoreConfig,
|
||||
} from './types';
|
||||
|
||||
// User Settings Constants
|
||||
export { DEFAULT_GLOBAL_SETTINGS, DEFAULT_GENERAL_SETTINGS } from './types';
|
||||
|
||||
// Theme Variant Categories
|
||||
export { DEFAULT_THEME_VARIANTS, EXTENDED_THEME_VARIANTS } from './types';
|
||||
|
||||
// Custom Theme Constants
|
||||
export { MAIN_THEME_COLORS, EXTENDED_THEME_COLORS, THEME_COLOR_LABELS } from './types';
|
||||
|
||||
// Constants
|
||||
export {
|
||||
THEME_VARIANTS,
|
||||
|
|
@ -63,6 +81,9 @@ export { createA11yStore } from './a11y-store.svelte';
|
|||
// User Settings Store
|
||||
export { createUserSettingsStore } from './user-settings-store.svelte';
|
||||
|
||||
// Custom Themes Store
|
||||
export { createCustomThemesStore } from './custom-themes-store.svelte';
|
||||
|
||||
// Utils
|
||||
export {
|
||||
isBrowser,
|
||||
|
|
|
|||
|
|
@ -5,9 +5,28 @@ export type ThemeMode = 'light' | 'dark' | 'system';
|
|||
|
||||
/**
|
||||
* Theme variant - visual style/color scheme
|
||||
* All apps share the same 4 variants
|
||||
* Default variants (shown in PillNav): lume, nature, stone, ocean
|
||||
* Extended variants (only on themes page, can be pinned): sunset, midnight, rose, lavender
|
||||
*/
|
||||
export type ThemeVariant = 'lume' | 'nature' | 'stone' | 'ocean';
|
||||
export type ThemeVariant =
|
||||
| 'lume'
|
||||
| 'nature'
|
||||
| 'stone'
|
||||
| 'ocean'
|
||||
| 'sunset'
|
||||
| 'midnight'
|
||||
| 'rose'
|
||||
| 'lavender';
|
||||
|
||||
/**
|
||||
* Default theme variants - always visible in PillNav
|
||||
*/
|
||||
export const DEFAULT_THEME_VARIANTS: ThemeVariant[] = ['lume', 'nature', 'stone', 'ocean'];
|
||||
|
||||
/**
|
||||
* Extended theme variants - only on themes page, can be pinned
|
||||
*/
|
||||
export const EXTENDED_THEME_VARIANTS: ThemeVariant[] = ['sunset', 'midnight', 'rose', 'lavender'];
|
||||
|
||||
/**
|
||||
* Effective mode - the actual rendered mode (resolved from system preference)
|
||||
|
|
@ -258,6 +277,8 @@ export interface ThemeSettings {
|
|||
mode: ThemeMode;
|
||||
/** Color scheme / variant */
|
||||
colorScheme: string;
|
||||
/** Pinned themes to show in PillNav (extended themes only) */
|
||||
pinnedThemes?: ThemeVariant[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -303,7 +324,7 @@ export const DEFAULT_GENERAL_SETTINGS: GeneralSettings = {
|
|||
*/
|
||||
export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
||||
nav: { desktopPosition: 'top', sidebarCollapsed: false },
|
||||
theme: { mode: 'system', colorScheme: 'ocean' },
|
||||
theme: { mode: 'system', colorScheme: 'ocean', pinnedThemes: [] },
|
||||
locale: 'de',
|
||||
general: DEFAULT_GENERAL_SETTINGS,
|
||||
};
|
||||
|
|
@ -356,3 +377,260 @@ export interface UserSettingsStoreConfig {
|
|||
/** Function to get current access token */
|
||||
getAccessToken: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Custom & Community Themes Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Partial theme colors for API DTOs (some fields optional)
|
||||
*/
|
||||
export interface ThemeColorsInput {
|
||||
primary: HSLValue;
|
||||
primaryForeground?: HSLValue;
|
||||
background: HSLValue;
|
||||
foreground: HSLValue;
|
||||
surface: HSLValue;
|
||||
surfaceHover?: HSLValue;
|
||||
surfaceElevated?: HSLValue;
|
||||
muted?: HSLValue;
|
||||
mutedForeground?: HSLValue;
|
||||
border?: HSLValue;
|
||||
borderStrong?: HSLValue;
|
||||
secondary?: HSLValue;
|
||||
secondaryForeground?: HSLValue;
|
||||
input?: HSLValue;
|
||||
ring?: HSLValue;
|
||||
error: HSLValue;
|
||||
success: HSLValue;
|
||||
warning: HSLValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-created custom theme
|
||||
*/
|
||||
export interface CustomTheme {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
emoji: string;
|
||||
icon: string;
|
||||
lightColors: ThemeColors;
|
||||
darkColors: ThemeColors;
|
||||
baseVariant?: ThemeVariant;
|
||||
isPublished: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for creating a new custom theme
|
||||
*/
|
||||
export interface CreateCustomThemeInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
emoji?: string;
|
||||
icon?: string;
|
||||
lightColors: ThemeColorsInput;
|
||||
darkColors: ThemeColorsInput;
|
||||
baseVariant?: ThemeVariant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for updating a custom theme
|
||||
*/
|
||||
export interface UpdateCustomThemeInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
emoji?: string;
|
||||
icon?: string;
|
||||
lightColors?: ThemeColorsInput;
|
||||
darkColors?: ThemeColorsInput;
|
||||
baseVariant?: ThemeVariant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Community theme shared publicly
|
||||
*/
|
||||
export interface CommunityTheme {
|
||||
id: string;
|
||||
authorId?: string;
|
||||
authorName?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
emoji: string;
|
||||
icon: string;
|
||||
lightColors: ThemeColors;
|
||||
darkColors: ThemeColors;
|
||||
baseVariant?: ThemeVariant;
|
||||
downloadCount: number;
|
||||
averageRating: number;
|
||||
ratingCount: number;
|
||||
status: 'pending' | 'approved' | 'rejected' | 'featured';
|
||||
isFeatured: boolean;
|
||||
tags: string[];
|
||||
createdAt: Date;
|
||||
publishedAt?: Date;
|
||||
/** User-specific fields (when authenticated) */
|
||||
isFavorited?: boolean;
|
||||
isDownloaded?: boolean;
|
||||
userRating?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query parameters for browsing community themes
|
||||
*/
|
||||
export interface CommunityThemeQuery {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sort?: 'popular' | 'recent' | 'rating' | 'downloads';
|
||||
search?: string;
|
||||
tags?: string[];
|
||||
authorId?: string;
|
||||
featuredOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated response for community themes
|
||||
*/
|
||||
export interface PaginatedCommunityThemes {
|
||||
themes: CommunityTheme[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for publishing a theme to the community
|
||||
*/
|
||||
export interface PublishThemeInput {
|
||||
tags?: string[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme editor state for UI
|
||||
*/
|
||||
export interface ThemeEditorState {
|
||||
/** Theme being edited */
|
||||
theme: Partial<CreateCustomThemeInput>;
|
||||
/** Currently editing light or dark colors */
|
||||
editingMode: EffectiveMode;
|
||||
/** Currently selected color key */
|
||||
selectedColorKey: keyof ThemeColors | null;
|
||||
/** Is preview mode active */
|
||||
isPreviewing: boolean;
|
||||
/** Has unsaved changes */
|
||||
isDirty: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom themes store interface
|
||||
*/
|
||||
export interface CustomThemesStore {
|
||||
/** User's custom themes */
|
||||
readonly customThemes: CustomTheme[];
|
||||
/** Community themes (from current query) */
|
||||
readonly communityThemes: CommunityTheme[];
|
||||
/** User's favorited themes */
|
||||
readonly favorites: CommunityTheme[];
|
||||
/** User's downloaded themes */
|
||||
readonly downloaded: CommunityTheme[];
|
||||
/** Pagination info */
|
||||
readonly pagination: { page: number; totalPages: number; total: number };
|
||||
/** Loading state */
|
||||
readonly loading: boolean;
|
||||
/** Error state */
|
||||
readonly error: string | null;
|
||||
|
||||
// Custom theme operations
|
||||
loadCustomThemes: () => Promise<void>;
|
||||
createTheme: (input: CreateCustomThemeInput) => Promise<CustomTheme>;
|
||||
updateTheme: (id: string, input: UpdateCustomThemeInput) => Promise<CustomTheme>;
|
||||
deleteTheme: (id: string) => Promise<void>;
|
||||
publishTheme: (id: string, input?: PublishThemeInput) => Promise<CommunityTheme>;
|
||||
|
||||
// Community theme operations
|
||||
browseCommunity: (query?: CommunityThemeQuery) => Promise<void>;
|
||||
downloadTheme: (id: string) => Promise<CommunityTheme>;
|
||||
rateTheme: (
|
||||
id: string,
|
||||
rating: number
|
||||
) => Promise<{ averageRating: number; ratingCount: number }>;
|
||||
toggleFavorite: (id: string) => Promise<{ isFavorited: boolean }>;
|
||||
loadFavorites: () => Promise<void>;
|
||||
loadDownloaded: () => Promise<void>;
|
||||
|
||||
// Apply theme
|
||||
applyCustomTheme: (theme: CustomTheme | CommunityTheme) => void;
|
||||
clearCustomTheme: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom themes store configuration
|
||||
*/
|
||||
export interface CustomThemesStoreConfig {
|
||||
/** Auth service base URL */
|
||||
authUrl: string;
|
||||
/** Function to get current access token */
|
||||
getAccessToken: () => Promise<string | null>;
|
||||
/** Theme store to apply custom themes to */
|
||||
themeStore?: ThemeStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main colors for the simplified editor view
|
||||
* These are the 7 most important colors users typically want to customize
|
||||
*/
|
||||
export const MAIN_THEME_COLORS: (keyof ThemeColors)[] = [
|
||||
'primary',
|
||||
'background',
|
||||
'surface',
|
||||
'foreground',
|
||||
'error',
|
||||
'success',
|
||||
'warning',
|
||||
];
|
||||
|
||||
/**
|
||||
* Extended/advanced colors (collapsed by default in editor)
|
||||
*/
|
||||
export const EXTENDED_THEME_COLORS: (keyof ThemeColors)[] = [
|
||||
'primaryForeground',
|
||||
'secondary',
|
||||
'secondaryForeground',
|
||||
'surfaceHover',
|
||||
'surfaceElevated',
|
||||
'muted',
|
||||
'mutedForeground',
|
||||
'border',
|
||||
'borderStrong',
|
||||
'input',
|
||||
'ring',
|
||||
];
|
||||
|
||||
/**
|
||||
* Color labels for the editor UI
|
||||
*/
|
||||
export const THEME_COLOR_LABELS: Record<keyof ThemeColors, string> = {
|
||||
primary: 'Primary',
|
||||
primaryForeground: 'Primary Text',
|
||||
secondary: 'Secondary',
|
||||
secondaryForeground: 'Secondary Text',
|
||||
background: 'Background',
|
||||
foreground: 'Text',
|
||||
surface: 'Surface',
|
||||
surfaceHover: 'Surface Hover',
|
||||
surfaceElevated: 'Elevated Surface',
|
||||
muted: 'Muted',
|
||||
mutedForeground: 'Muted Text',
|
||||
border: 'Border',
|
||||
borderStrong: 'Border Strong',
|
||||
error: 'Error',
|
||||
success: 'Success',
|
||||
warning: 'Warning',
|
||||
input: 'Input',
|
||||
ring: 'Focus Ring',
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue