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

@ -27,18 +27,15 @@
"@google/generative-ai": "^0.24.1",
"@manacore/shared-errors": "workspace:*",
"@manacore/shared-nestjs-auth": "workspace:*",
"@manacore/shared-storage": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@types/multer": "^1.4.11",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.3",
"multer": "^1.4.5-lts.1",
"openai": "^4.77.0",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",

View file

@ -8,7 +8,6 @@ import { SpaceModule } from './space/space.module';
import { DocumentModule } from './document/document.module';
import { ModelModule } from './model/model.module';
import { HealthModule } from './health/health.module';
import { StorageModule } from './storage/storage.module';
@Module({
imports: [
@ -24,7 +23,6 @@ import { StorageModule } from './storage/storage.module';
DocumentModule,
ModelModule,
HealthModule,
StorageModule,
],
})
export class AppModule {}

View file

@ -1,3 +0,0 @@
export * from './storage.module';
export * from './storage.service';
export * from './storage.controller';

View file

@ -1,137 +0,0 @@
import {
Controller,
Post,
Get,
Delete,
Param,
Body,
UseInterceptors,
UploadedFile,
BadRequestException,
NotFoundException,
UseGuards,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { StorageService } from './storage.service';
interface PresignedUploadRequest {
filename: string;
folder?: string;
}
@Controller('api/storage')
@UseGuards(JwtAuthGuard)
export class StorageController {
constructor(private readonly storageService: StorageService) {}
/**
* Upload a file directly
*/
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
async uploadFile(
@CurrentUser() user: CurrentUserData,
@UploadedFile() file: Express.Multer.File,
@Body('folder') folder?: string
) {
if (!file) {
throw new BadRequestException('No file provided');
}
const result = await this.storageService.uploadFile(
user.userId,
file.originalname,
file.buffer,
{
folder,
}
);
return {
success: true,
data: result,
};
}
/**
* Get a presigned URL for client-side upload
*/
@Post('presigned-upload')
async getPresignedUpload(
@CurrentUser() user: CurrentUserData,
@Body() body: PresignedUploadRequest
) {
if (!body.filename) {
throw new BadRequestException('Filename is required');
}
const result = await this.storageService.getPresignedUploadUrl(user.userId, body.filename, {
folder: body.folder,
});
return {
success: true,
data: result,
};
}
/**
* Get a presigned URL for downloading
*/
@Get('download/:key(*)')
async getDownloadUrl(@CurrentUser() user: CurrentUserData, @Param('key') key: string) {
// Ensure user can only access their own files
if (!key.startsWith(`users/${user.userId}/`)) {
throw new NotFoundException('File not found');
}
const exists = await this.storageService.fileExists(key);
if (!exists) {
throw new NotFoundException('File not found');
}
const url = await this.storageService.getPresignedDownloadUrl(key);
return {
success: true,
data: { url },
};
}
/**
* Delete a file
*/
@Delete(':key(*)')
async deleteFile(@CurrentUser() user: CurrentUserData, @Param('key') key: string) {
// Ensure user can only delete their own files
if (!key.startsWith(`users/${user.userId}/`)) {
throw new NotFoundException('File not found');
}
const exists = await this.storageService.fileExists(key);
if (!exists) {
throw new NotFoundException('File not found');
}
await this.storageService.deleteFile(key);
return {
success: true,
message: 'File deleted',
};
}
/**
* List user's files
*/
@Get('list')
async listFiles(@CurrentUser() user: CurrentUserData, @Body('folder') folder?: string) {
const files = await this.storageService.listUserFiles(user.userId, folder);
return {
success: true,
data: { files },
};
}
}

View file

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { StorageService } from './storage.service';
import { StorageController } from './storage.controller';
@Module({
controllers: [StorageController],
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View file

@ -1,152 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import {
createChatStorage,
generateUserFileKey,
getContentType,
validateFileSize,
validateFileExtension,
IMAGE_EXTENSIONS,
DOCUMENT_EXTENSIONS,
AUDIO_EXTENSIONS,
} from '@manacore/shared-storage';
import type { StorageClient, UploadResult } from '@manacore/shared-storage';
export interface FileUploadResult {
key: string;
url?: string;
contentType: string;
size: number;
}
export interface PresignedUploadData {
uploadUrl: string;
key: string;
expiresIn: number;
}
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
const ALLOWED_EXTENSIONS = [...IMAGE_EXTENSIONS, ...DOCUMENT_EXTENSIONS, ...AUDIO_EXTENSIONS];
@Injectable()
export class StorageService {
private readonly logger = new Logger(StorageService.name);
private storage: StorageClient | null = null;
private getStorage(): StorageClient {
if (!this.storage) {
this.storage = createChatStorage();
}
return this.storage;
}
/**
* Upload a file to storage
*/
async uploadFile(
userId: string,
filename: string,
data: Buffer,
options?: { folder?: string; public?: boolean }
): Promise<FileUploadResult> {
// Validate file size (MAX_FILE_SIZE is in bytes)
if (!validateFileSize(data.length, MAX_FILE_SIZE / (1024 * 1024))) {
throw new Error(`File size exceeds maximum allowed (${MAX_FILE_SIZE / (1024 * 1024)}MB)`);
}
// Validate file extension
if (!validateFileExtension(filename, ALLOWED_EXTENSIONS)) {
throw new Error(
`File type not allowed. Allowed extensions: ${ALLOWED_EXTENSIONS.join(', ')}`
);
}
const contentType = getContentType(filename);
const key = generateUserFileKey(userId, filename, options?.folder);
const storage = this.getStorage();
const result: UploadResult = await storage.upload(key, data, {
contentType,
public: options?.public ?? false,
});
this.logger.log(`File uploaded: ${key} (${data.length} bytes)`);
return {
key: result.key,
url: result.url,
contentType,
size: data.length,
};
}
/**
* Get a presigned URL for uploading (client-side upload)
*/
async getPresignedUploadUrl(
userId: string,
filename: string,
options?: { folder?: string; expiresIn?: number }
): Promise<PresignedUploadData> {
// Validate file extension
if (!validateFileExtension(filename, ALLOWED_EXTENSIONS)) {
throw new Error(
`File type not allowed. Allowed extensions: ${ALLOWED_EXTENSIONS.join(', ')}`
);
}
const key = generateUserFileKey(userId, filename, options?.folder);
const expiresIn = options?.expiresIn ?? 3600; // 1 hour default
const storage = this.getStorage();
const uploadUrl = await storage.getUploadUrl(key, { expiresIn });
return {
uploadUrl,
key,
expiresIn,
};
}
/**
* Get a presigned URL for downloading
*/
async getPresignedDownloadUrl(key: string, expiresIn = 3600): Promise<string> {
const storage = this.getStorage();
return storage.getDownloadUrl(key, { expiresIn });
}
/**
* Download a file from storage
*/
async downloadFile(key: string): Promise<Buffer> {
const storage = this.getStorage();
return storage.download(key);
}
/**
* Delete a file from storage
*/
async deleteFile(key: string): Promise<void> {
const storage = this.getStorage();
await storage.delete(key);
this.logger.log(`File deleted: ${key}`);
}
/**
* Check if a file exists
*/
async fileExists(key: string): Promise<boolean> {
const storage = this.getStorage();
return storage.exists(key);
}
/**
* List files for a user
*/
async listUserFiles(userId: string, folder?: string): Promise<string[]> {
const storage = this.getStorage();
const prefix = folder ? `users/${userId}/${folder}/` : `users/${userId}/`;
const files = await storage.list(prefix);
return files.map((f) => f.key);
}
}

View file

@ -6,3 +6,5 @@
@source "../../../../packages/shared-auth-ui/src";
@source "../../../../packages/shared-branding/src";
@source "../../../../packages/shared-theme-ui/src";
@source "../../../../packages/shared-theme-ui/src/components";
@source "../../../../packages/shared-theme-ui/src/pages";

View file

@ -5,21 +5,21 @@
*/
import type { Handle } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
// Get client-side URLs from environment (Docker runtime)
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 }) => {
// Get client-side URLs from environment at RUNTIME (not build time)
// Use $env/dynamic/private to read actual runtime environment variables
const authUrlClient = env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || env.PUBLIC_MANA_CORE_AUTH_URL || '';
const backendUrlClient = env.PUBLIC_BACKEND_URL_CLIENT || env.PUBLIC_BACKEND_URL || '';
return resolve(event, {
transformPageChunk: ({ html }) => {
// Inject runtime environment variables into the HTML
// These will be available on window.__PUBLIC_*__ for client-side code
const envScript = `<script>
window.__PUBLIC_MANA_CORE_AUTH_URL__ = "${authUrlClient}";
window.__PUBLIC_BACKEND_URL__ = "${backendUrlClient}";
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

@ -6,7 +6,12 @@
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { theme } from '$lib/stores/theme';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
import {
THEME_DEFINITIONS,
DEFAULT_THEME_VARIANTS,
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
@ -30,10 +35,20 @@
// Use theme store's isDark directly
let isDark = $derived(theme.isDark);
// Get pinned themes from user settings (extended themes only)
let pinnedThemes = $derived<ThemeVariant[]>(
(userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant =>
EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant)
)
);
// Visible themes in PillNav: default + pinned extended
let visibleThemes = $derived<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]);
// Theme variant dropdown items
let themeVariantItems = $derived<PillDropdownItem[]>([
// Theme variants
...theme.variants.map((variant) => ({
// Theme variants (only default + pinned)
...visibleThemes.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant].label,
icon: THEME_DEFINITIONS[variant].icon,