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:
Till-JS 2025-12-10 02:37:46 +01:00
parent e84371aa94
commit ee42b6cc76
381 changed files with 39284 additions and 6275 deletions

View file

@ -0,0 +1,52 @@
/**
* @manacore/shared-help-content
* Central help content and utilities for loading, parsing, and searching
*/
// Parser utilities
export {
parseMarkdown,
parseMarkdownFiles,
stripHtml,
generateExcerpt,
type ParsedContent,
type ParseOptions,
} from './parser.js';
// Content loader
export {
parseFAQContent,
parseFeatureContent,
parseShortcutsContent,
parseGettingStartedContent,
parseChangelogContent,
parseContactContent,
loadHelpContentFromFiles,
type LoaderOptions,
} from './loader.js';
// Content merger
export { mergeContent, createEmptyContent } from './merger.js';
// Search functionality
export { buildSearchIndex, search, createSearcher, flattenContentForSearch } from './search.js';
// Re-export types for convenience
export type {
HelpContent,
FAQItem,
FeatureItem,
ShortcutsItem,
GettingStartedItem,
ChangelogItem,
ContactInfo,
SupportedLanguage,
MergeContentOptions,
} from '@manacore/shared-help-types';
export type {
SearchResult,
SearchOptions,
SearchIndexConfig,
SearchableItem,
} from '@manacore/shared-help-types';

View file

@ -0,0 +1,292 @@
/**
* Content Loader
* Utilities for loading help content from various sources
*/
import type {
HelpContent,
FAQItem,
FeatureItem,
ShortcutsItem,
GettingStartedItem,
ChangelogItem,
ContactInfo,
SupportedLanguage,
} from '@manacore/shared-help-types';
import {
faqFrontmatterSchema,
featureFrontmatterSchema,
shortcutsFrontmatterSchema,
gettingStartedFrontmatterSchema,
changelogFrontmatterSchema,
contactFrontmatterSchema,
} from '@manacore/shared-help-types';
import { parseMarkdown } from './parser.js';
import { createEmptyContent } from './merger.js';
export interface LoaderOptions {
/** Locale to load */
locale: SupportedLanguage;
/** Fallback locale if content not found */
fallbackLocale?: SupportedLanguage;
}
/**
* Parse FAQ content from raw Markdown
*/
export function parseFAQContent(rawContent: string): FAQItem {
const parsed = parseMarkdown(rawContent, faqFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
return {
id: fm.id as string,
language: fm.language as SupportedLanguage,
order: fm.order as number | undefined,
appSpecific: fm.appSpecific as boolean | undefined,
apps: fm.apps as string[] | undefined,
lastUpdated: fm.lastUpdated as Date | undefined,
question: fm.question as string,
category: fm.category as FAQItem['category'],
featured: fm.featured as boolean | undefined,
tags: fm.tags as string[] | undefined,
relatedFaqs: fm.relatedFaqs as string[] | undefined,
answer: parsed.html,
};
}
/**
* Parse Feature content from raw Markdown
*/
export function parseFeatureContent(rawContent: string): FeatureItem {
const parsed = parseMarkdown(rawContent, featureFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
return {
id: fm.id as string,
language: fm.language as SupportedLanguage,
order: fm.order as number | undefined,
appSpecific: fm.appSpecific as boolean | undefined,
apps: fm.apps as string[] | undefined,
lastUpdated: fm.lastUpdated as Date | undefined,
title: fm.title as string,
description: fm.description as string,
icon: fm.icon as string | undefined,
category: fm.category as FeatureItem['category'],
available: fm.available as boolean | undefined,
comingSoon: fm.comingSoon as boolean | undefined,
highlights: fm.highlights as string[] | undefined,
learnMoreUrl: fm.learnMoreUrl as string | undefined,
content: parsed.html,
};
}
/**
* Parse Shortcuts content from raw Markdown
*/
export function parseShortcutsContent(rawContent: string): ShortcutsItem {
const parsed = parseMarkdown(rawContent, shortcutsFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
// Parse markdown table to extract shortcuts
const shortcuts = parseShortcutsTable(parsed.content);
return {
id: fm.id as string,
language: fm.language as SupportedLanguage,
order: fm.order as number | undefined,
appSpecific: fm.appSpecific as boolean | undefined,
apps: fm.apps as string[] | undefined,
lastUpdated: fm.lastUpdated as Date | undefined,
category: fm.category as ShortcutsItem['category'],
title: fm.title as string | undefined,
shortcuts,
};
}
/**
* Parse a markdown table into keyboard shortcuts
*/
function parseShortcutsTable(
content: string
): Array<{ shortcut: string; action: string; description?: string }> {
const shortcuts: Array<{ shortcut: string; action: string; description?: string }> = [];
const lines = content.split('\n');
let inTable = false;
for (const line of lines) {
const trimmed = line.trim();
// Skip header separator
if (trimmed.match(/^\|[-:\s|]+\|$/)) {
inTable = true;
continue;
}
// Parse table row
if (inTable && trimmed.startsWith('|') && trimmed.endsWith('|')) {
const cells = trimmed
.slice(1, -1)
.split('|')
.map((cell) => cell.trim());
if (cells.length >= 2) {
shortcuts.push({
shortcut: cells[0],
action: cells[1],
description: cells[2] || undefined,
});
}
} else if (inTable && !trimmed.startsWith('|')) {
// End of table
break;
}
}
return shortcuts;
}
/**
* Parse Getting Started guide content from raw Markdown
*/
export function parseGettingStartedContent(rawContent: string): GettingStartedItem {
const parsed = parseMarkdown(rawContent, gettingStartedFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
// Extract steps from content (h2 headers)
const steps = parseGuideSteps(parsed.content);
return {
id: fm.id as string,
language: fm.language as SupportedLanguage,
order: fm.order as number | undefined,
appSpecific: fm.appSpecific as boolean | undefined,
apps: fm.apps as string[] | undefined,
lastUpdated: fm.lastUpdated as Date | undefined,
title: fm.title as string,
description: fm.description as string,
difficulty: fm.difficulty as GettingStartedItem['difficulty'],
estimatedTime: fm.estimatedTime as string | undefined,
prerequisites: fm.prerequisites as string[] | undefined,
content: parsed.html,
steps,
};
}
/**
* Parse guide steps from markdown content (h2 headers)
*/
function parseGuideSteps(content: string): Array<{ title: string; content: string }> {
const steps: Array<{ title: string; content: string }> = [];
const sections = content.split(/^## /m);
for (let i = 1; i < sections.length; i++) {
const section = sections[i];
const newlineIndex = section.indexOf('\n');
const title = section.substring(0, newlineIndex).trim();
const stepContent = section.substring(newlineIndex + 1).trim();
steps.push({ title, content: stepContent });
}
return steps;
}
/**
* Parse Changelog content from raw Markdown
*/
export function parseChangelogContent(rawContent: string): ChangelogItem {
const parsed = parseMarkdown(rawContent, changelogFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
return {
id: fm.id as string,
language: fm.language as SupportedLanguage,
order: fm.order as number | undefined,
appSpecific: fm.appSpecific as boolean | undefined,
apps: fm.apps as string[] | undefined,
lastUpdated: fm.lastUpdated as Date | undefined,
version: fm.version as string,
title: fm.title as string,
releaseDate: fm.releaseDate as Date,
type: fm.type as ChangelogItem['type'],
summary: fm.summary as string | undefined,
highlighted: fm.highlighted as boolean | undefined,
changes: fm.changes as ChangelogItem['changes'],
platforms: fm.platforms as string[] | undefined,
content: parsed.html,
};
}
/**
* Parse Contact content from raw Markdown
*/
export function parseContactContent(rawContent: string): ContactInfo {
const parsed = parseMarkdown(rawContent, contactFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
return {
id: fm.id as string,
language: fm.language as SupportedLanguage,
order: fm.order as number | undefined,
appSpecific: fm.appSpecific as boolean | undefined,
apps: fm.apps as string[] | undefined,
lastUpdated: fm.lastUpdated as Date | undefined,
title: fm.title as string,
supportEmail: fm.supportEmail as string | undefined,
supportUrl: fm.supportUrl as string | undefined,
discordUrl: fm.discordUrl as string | undefined,
twitterUrl: fm.twitterUrl as string | undefined,
documentationUrl: fm.documentationUrl as string | undefined,
responseTime: fm.responseTime as string | undefined,
content: parsed.html,
};
}
/**
* Load help content from a map of file paths to content
* This is the main entry point for content loading
*/
export function loadHelpContentFromFiles(
files: Record<string, string>,
options: LoaderOptions
): HelpContent {
const content = createEmptyContent();
const { locale, fallbackLocale = 'en' } = options;
for (const [path, rawContent] of Object.entries(files)) {
try {
// Determine content type from path
if (path.includes('/faq/')) {
const faq = parseFAQContent(rawContent);
if (faq.language === locale || faq.language === fallbackLocale) {
content.faq.push(faq);
}
} else if (path.includes('/features/')) {
const feature = parseFeatureContent(rawContent);
if (feature.language === locale || feature.language === fallbackLocale) {
content.features.push(feature);
}
} else if (path.includes('/shortcuts/')) {
const shortcuts = parseShortcutsContent(rawContent);
if (shortcuts.language === locale || shortcuts.language === fallbackLocale) {
content.shortcuts.push(shortcuts);
}
} else if (path.includes('/getting-started/')) {
const guide = parseGettingStartedContent(rawContent);
if (guide.language === locale || guide.language === fallbackLocale) {
content.gettingStarted.push(guide);
}
} else if (path.includes('/changelog/')) {
const changelog = parseChangelogContent(rawContent);
if (changelog.language === locale || changelog.language === fallbackLocale) {
content.changelog.push(changelog);
}
} else if (path.includes('/contact/')) {
const contact = parseContactContent(rawContent);
if (contact.language === locale || contact.language === fallbackLocale) {
content.contact = contact;
}
}
} catch {
// Skip files that fail to parse
}
}
return content;
}

View file

@ -0,0 +1,119 @@
/**
* Content Merger
* Merges central help content with app-specific content
*/
import type { HelpContent, MergeContentOptions } from '@manacore/shared-help-types';
/**
* Filter content items by locale and app
*/
function filterItems<T extends { language: string; appSpecific?: boolean; apps?: string[] }>(
items: T[],
locale: string,
appId: string
): T[] {
return items.filter((item) => {
// Filter by language
if (item.language !== locale) {
return false;
}
// Include non-app-specific items
if (!item.appSpecific) {
return true;
}
// Include app-specific items for this app
return item.apps?.includes(appId) ?? false;
});
}
/**
* Merge two arrays, optionally replacing items with same ID
*/
function mergeArrays<T extends { id: string }>(
central: T[],
appSpecific: T[],
overrideById: boolean
): T[] {
if (!overrideById) {
return [...central, ...appSpecific];
}
const appIds = new Set(appSpecific.map((item) => item.id));
const filtered = central.filter((item) => !appIds.has(item.id));
return [...filtered, ...appSpecific];
}
/**
* Sort items by order property
*/
function sortByOrder<T extends { order?: number }>(items: T[]): T[] {
return [...items].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
/**
* Merge central help content with app-specific content
*/
export function mergeContent(
central: HelpContent,
appSpecific: Partial<HelpContent>,
options: MergeContentOptions
): HelpContent {
const { appId, locale, overrideById = true } = options;
// Filter central content by locale and app
const filteredCentral: HelpContent = {
faq: filterItems(central.faq, locale, appId),
features: filterItems(central.features, locale, appId),
shortcuts: filterItems(central.shortcuts, locale, appId),
gettingStarted: filterItems(central.gettingStarted, locale, appId),
changelog: filterItems(central.changelog, locale, appId),
contact: central.contact?.language === locale ? central.contact : null,
};
// Filter app-specific content
const filteredApp: Partial<HelpContent> = {
faq: appSpecific.faq ? filterItems(appSpecific.faq, locale, appId) : [],
features: appSpecific.features ? filterItems(appSpecific.features, locale, appId) : [],
shortcuts: appSpecific.shortcuts ? filterItems(appSpecific.shortcuts, locale, appId) : [],
gettingStarted: appSpecific.gettingStarted
? filterItems(appSpecific.gettingStarted, locale, appId)
: [],
changelog: appSpecific.changelog ? filterItems(appSpecific.changelog, locale, appId) : [],
contact: appSpecific.contact?.language === locale ? appSpecific.contact : null,
};
// Merge and sort
return {
faq: sortByOrder(mergeArrays(filteredCentral.faq, filteredApp.faq ?? [], overrideById)),
features: sortByOrder(
mergeArrays(filteredCentral.features, filteredApp.features ?? [], overrideById)
),
shortcuts: sortByOrder(
mergeArrays(filteredCentral.shortcuts, filteredApp.shortcuts ?? [], overrideById)
),
gettingStarted: sortByOrder(
mergeArrays(filteredCentral.gettingStarted, filteredApp.gettingStarted ?? [], overrideById)
),
changelog: sortByOrder(
mergeArrays(filteredCentral.changelog, filteredApp.changelog ?? [], overrideById)
),
contact: filteredApp.contact ?? filteredCentral.contact,
};
}
/**
* Create an empty HelpContent object
*/
export function createEmptyContent(): HelpContent {
return {
faq: [],
features: [],
shortcuts: [],
gettingStarted: [],
changelog: [],
contact: null,
};
}

View file

@ -0,0 +1,86 @@
/**
* Markdown + Frontmatter Parser
* Parses Markdown files with YAML frontmatter
*/
import matter from 'gray-matter';
import { marked } from 'marked';
import type { ZodSchema } from 'zod';
export interface ParsedContent<T> {
frontmatter: T;
content: string;
html: string;
}
export interface ParseOptions {
/** Convert Markdown to HTML */
renderHtml?: boolean;
}
/**
* Parse a Markdown file with frontmatter
*/
export function parseMarkdown<T>(
rawContent: string,
schema?: ZodSchema<T>,
options: ParseOptions = { renderHtml: true }
): ParsedContent<T> {
const { data, content } = matter(rawContent);
// Validate frontmatter if schema provided
let frontmatter: T;
if (schema) {
const result = schema.safeParse(data);
if (!result.success) {
throw new Error(`Invalid frontmatter: ${result.error.message}`);
}
frontmatter = result.data;
} else {
frontmatter = data as T;
}
// Render HTML if requested
const html = options.renderHtml ? (marked.parse(content) as string) : '';
return {
frontmatter,
content: content.trim(),
html,
};
}
/**
* Parse multiple Markdown files
*/
export function parseMarkdownFiles<T>(
files: { filename: string; content: string }[],
schema?: ZodSchema<T>,
options?: ParseOptions
): Array<ParsedContent<T> & { filename: string }> {
return files.map(({ filename, content }) => ({
filename,
...parseMarkdown<T>(content, schema, options),
}));
}
/**
* Extract text content from HTML (for search indexing)
*/
export function stripHtml(html: string): string {
return html
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Generate excerpt from content
*/
export function generateExcerpt(content: string, maxLength = 150): string {
const text = stripHtml(content);
if (text.length <= maxLength) {
return text;
}
return text.substring(0, maxLength).trim() + '...';
}

View file

@ -0,0 +1,209 @@
/**
* Search Functionality using Fuse.js
* Provides full-text search across help content
*/
import Fuse, { type IFuseOptions } from 'fuse.js';
import type {
HelpContent,
FAQItem,
FeatureItem,
GettingStartedItem,
ChangelogItem,
} from '@manacore/shared-help-types';
import type {
SearchableItem,
SearchResult,
SearchOptions,
SearchIndexConfig,
} from '@manacore/shared-help-types';
import { generateExcerpt, stripHtml } from './parser.js';
const DEFAULT_CONFIG: SearchIndexConfig = {
titleWeight: 2,
contentWeight: 1,
tagsWeight: 1.5,
threshold: 0.3,
minMatchCharLength: 2,
};
/**
* Convert HelpContent to searchable items
*/
export function flattenContentForSearch(content: HelpContent): SearchableItem[] {
const items: SearchableItem[] = [];
// FAQs
for (const faq of content.faq) {
items.push({
id: faq.id,
type: 'faq',
title: faq.question,
question: faq.question,
content: stripHtml(faq.answer),
tags: faq.tags,
});
}
// Features
for (const feature of content.features) {
items.push({
id: feature.id,
type: 'feature',
title: feature.title,
description: feature.description,
content: stripHtml(feature.content),
tags: feature.highlights,
});
}
// Getting Started Guides
for (const guide of content.gettingStarted) {
items.push({
id: guide.id,
type: 'guide',
title: guide.title,
description: guide.description,
content: stripHtml(guide.content),
});
}
// Changelog
for (const log of content.changelog) {
items.push({
id: log.id,
type: 'changelog',
title: `${log.version} - ${log.title}`,
content: stripHtml(log.content),
description: log.summary,
});
}
return items;
}
/**
* Build a Fuse.js search index from help content
*/
export function buildSearchIndex(
content: HelpContent,
config: SearchIndexConfig = DEFAULT_CONFIG
): Fuse<SearchableItem> {
const items = flattenContentForSearch(content);
const fuseOptions: IFuseOptions<SearchableItem> = {
keys: [
{ name: 'title', weight: config.titleWeight ?? 2 },
{ name: 'question', weight: config.titleWeight ?? 2 },
{ name: 'content', weight: config.contentWeight ?? 1 },
{ name: 'description', weight: config.contentWeight ?? 1 },
{ name: 'tags', weight: config.tagsWeight ?? 1.5 },
],
threshold: config.threshold ?? 0.3,
includeScore: true,
minMatchCharLength: config.minMatchCharLength ?? 2,
ignoreLocation: true,
};
return new Fuse(items, fuseOptions);
}
/**
* Find the original item from content
*/
function findOriginalItem(
id: string,
type: string,
content: HelpContent
): FAQItem | FeatureItem | GettingStartedItem | ChangelogItem | null {
switch (type) {
case 'faq':
return content.faq.find((item) => item.id === id) ?? null;
case 'feature':
return content.features.find((item) => item.id === id) ?? null;
case 'guide':
return content.gettingStarted.find((item) => item.id === id) ?? null;
case 'changelog':
return content.changelog.find((item) => item.id === id) ?? null;
default:
return null;
}
}
/**
* Highlight matching text in content
*/
function highlightMatch(text: string, query: string): string {
if (!query.trim()) return text;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<mark>$1</mark>');
}
/**
* Search help content
*/
export function search(
index: Fuse<SearchableItem>,
query: string,
content: HelpContent,
options: SearchOptions = {}
): SearchResult[] {
const { limit = 10, threshold, types, appId } = options;
if (!query.trim()) {
return [];
}
let results = index.search(query, { limit: limit * 2 });
// Filter by type if specified
if (types && types.length > 0) {
results = results.filter((r) => types.includes(r.item.type));
}
// Filter by app if specified
if (appId) {
results = results.filter((r) => {
const originalItem = findOriginalItem(r.item.id, r.item.type, content);
if (!originalItem) return true;
if (!originalItem.appSpecific) return true;
return originalItem.apps?.includes(appId);
});
}
// Apply threshold filter if specified
if (threshold !== undefined) {
results = results.filter((r) => (r.score ?? 1) <= threshold);
}
// Limit results
results = results.slice(0, limit);
const mappedResults: SearchResult[] = [];
for (const result of results) {
const originalItem = findOriginalItem(result.item.id, result.item.type, content);
if (!originalItem) continue;
mappedResults.push({
id: result.item.id,
type: result.item.type,
title: result.item.title,
excerpt: generateExcerpt(result.item.content, 150),
score: result.score ?? 1,
highlight: highlightMatch(result.item.title, query),
item: originalItem,
});
}
return mappedResults;
}
/**
* Create a search function with pre-built index
*/
export function createSearcher(content: HelpContent, config?: SearchIndexConfig) {
const index = buildSearchIndex(content, config);
return (query: string, options?: SearchOptions) => search(index, query, content, options);
}