mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 18:46:42 +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
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
export * from './storage.module';
|
||||
export * from './storage.service';
|
||||
export * from './storage.controller';
|
||||
|
|
@ -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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue