feat: add domains, ideas, layouts, widgets API modules
- Add DomainsModule with full CRUD, search, and activity logging - Add IdeasModule with quick capture endpoint - Add LayoutsModule for user dashboard layouts - Add WidgetsModule for widget definitions (read-only) - Update ActivityService with domain/idea logging methods - Register all new modules in AppModule
This commit is contained in:
@@ -534,6 +534,9 @@ model UserLayout {
|
|||||||
// Layout configuration (array of widget placements)
|
// Layout configuration (array of widget placements)
|
||||||
layout Json @default("[]")
|
layout Json @default("[]")
|
||||||
|
|
||||||
|
// Additional metadata for the layout
|
||||||
|
metadata Json @default("{}")
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { ActivityAction, EntityType } from "@prisma/client";
|
import { ActivityAction, EntityType, Prisma } from "@prisma/client";
|
||||||
import type {
|
import type {
|
||||||
CreateActivityLogInput,
|
CreateActivityLogInput,
|
||||||
PaginatedActivityLogs,
|
PaginatedActivityLogs,
|
||||||
@@ -23,7 +23,7 @@ export class ActivityService {
|
|||||||
async logActivity(input: CreateActivityLogInput) {
|
async logActivity(input: CreateActivityLogInput) {
|
||||||
try {
|
try {
|
||||||
return await this.prisma.activityLog.create({
|
return await this.prisma.activityLog.create({
|
||||||
data: input,
|
data: input as unknown as Prisma.ActivityLogCreateInput,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error("Failed to log activity", error);
|
this.logger.error("Failed to log activity", error);
|
||||||
@@ -167,7 +167,7 @@ export class ActivityService {
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
details?: Record<string, any>
|
details?: Prisma.JsonValue
|
||||||
) {
|
) {
|
||||||
return this.logActivity({
|
return this.logActivity({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -186,7 +186,7 @@ export class ActivityService {
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
details?: Record<string, any>
|
details?: Prisma.JsonValue
|
||||||
) {
|
) {
|
||||||
return this.logActivity({
|
return this.logActivity({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -205,7 +205,7 @@ export class ActivityService {
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
details?: Record<string, any>
|
details?: Prisma.JsonValue
|
||||||
) {
|
) {
|
||||||
return this.logActivity({
|
return this.logActivity({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -224,7 +224,7 @@ export class ActivityService {
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
details?: Record<string, any>
|
details?: Prisma.JsonValue
|
||||||
) {
|
) {
|
||||||
return this.logActivity({
|
return this.logActivity({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -262,7 +262,7 @@ export class ActivityService {
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
eventId: string,
|
eventId: string,
|
||||||
details?: Record<string, any>
|
details?: Prisma.JsonValue
|
||||||
) {
|
) {
|
||||||
return this.logActivity({
|
return this.logActivity({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -281,7 +281,7 @@ export class ActivityService {
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
eventId: string,
|
eventId: string,
|
||||||
details?: Record<string, any>
|
details?: Prisma.JsonValue
|
||||||
) {
|
) {
|
||||||
return this.logActivity({
|
return this.logActivity({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -300,7 +300,7 @@ export class ActivityService {
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
eventId: string,
|
eventId: string,
|
||||||
details?: Record<string, any>
|
details?: Prisma.JsonValue
|
||||||
) {
|
) {
|
||||||
return this.logActivity({
|
return this.logActivity({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -319,7 +319,7 @@ export class ActivityService {
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
details?: Record<string, any>
|
details?: Prisma.JsonValue
|
||||||
) {
|
) {
|
||||||
return this.logActivity({
|
return this.logActivity({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -338,7 +338,7 @@ export class ActivityService {
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
details?: Record<string, any>
|
details?: Prisma.JsonValue
|
||||||
) {
|
) {
|
||||||
return this.logActivity({
|
return this.logActivity({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -357,7 +357,7 @@ export class ActivityService {
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
details?: Record<string, any>
|
details?: Prisma.JsonValue
|
||||||
) {
|
) {
|
||||||
return this.logActivity({
|
return this.logActivity({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -375,7 +375,7 @@ export class ActivityService {
|
|||||||
async logWorkspaceCreated(
|
async logWorkspaceCreated(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
details?: Record<string, any>
|
details?: Prisma.JsonValue
|
||||||
) {
|
) {
|
||||||
return this.logActivity({
|
return this.logActivity({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -393,7 +393,7 @@ export class ActivityService {
|
|||||||
async logWorkspaceUpdated(
|
async logWorkspaceUpdated(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
details?: Record<string, any>
|
details?: Prisma.JsonValue
|
||||||
) {
|
) {
|
||||||
return this.logActivity({
|
return this.logActivity({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -448,7 +448,7 @@ export class ActivityService {
|
|||||||
async logUserUpdated(
|
async logUserUpdated(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
details?: Record<string, any>
|
details?: Prisma.JsonValue
|
||||||
) {
|
) {
|
||||||
return this.logActivity({
|
return this.logActivity({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -459,4 +459,118 @@ export class ActivityService {
|
|||||||
...(details && { details }),
|
...(details && { details }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log domain creation
|
||||||
|
*/
|
||||||
|
async logDomainCreated(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
domainId: string,
|
||||||
|
details?: Prisma.JsonValue
|
||||||
|
) {
|
||||||
|
return this.logActivity({
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
action: ActivityAction.CREATED,
|
||||||
|
entityType: EntityType.DOMAIN,
|
||||||
|
entityId: domainId,
|
||||||
|
...(details && { details }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log domain update
|
||||||
|
*/
|
||||||
|
async logDomainUpdated(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
domainId: string,
|
||||||
|
details?: Prisma.JsonValue
|
||||||
|
) {
|
||||||
|
return this.logActivity({
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
action: ActivityAction.UPDATED,
|
||||||
|
entityType: EntityType.DOMAIN,
|
||||||
|
entityId: domainId,
|
||||||
|
...(details && { details }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log domain deletion
|
||||||
|
*/
|
||||||
|
async logDomainDeleted(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
domainId: string,
|
||||||
|
details?: Prisma.JsonValue
|
||||||
|
) {
|
||||||
|
return this.logActivity({
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
action: ActivityAction.DELETED,
|
||||||
|
entityType: EntityType.DOMAIN,
|
||||||
|
entityId: domainId,
|
||||||
|
...(details && { details }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log idea creation
|
||||||
|
*/
|
||||||
|
async logIdeaCreated(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
ideaId: string,
|
||||||
|
details?: Prisma.JsonValue
|
||||||
|
) {
|
||||||
|
return this.logActivity({
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
action: ActivityAction.CREATED,
|
||||||
|
entityType: EntityType.IDEA,
|
||||||
|
entityId: ideaId,
|
||||||
|
...(details && { details }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log idea update
|
||||||
|
*/
|
||||||
|
async logIdeaUpdated(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
ideaId: string,
|
||||||
|
details?: Prisma.JsonValue
|
||||||
|
) {
|
||||||
|
return this.logActivity({
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
action: ActivityAction.UPDATED,
|
||||||
|
entityType: EntityType.IDEA,
|
||||||
|
entityId: ideaId,
|
||||||
|
...(details && { details }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log idea deletion
|
||||||
|
*/
|
||||||
|
async logIdeaDeleted(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
ideaId: string,
|
||||||
|
details?: Prisma.JsonValue
|
||||||
|
) {
|
||||||
|
return this.logActivity({
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
action: ActivityAction.DELETED,
|
||||||
|
entityType: EntityType.IDEA,
|
||||||
|
entityId: ideaId,
|
||||||
|
...(details && { details }),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface CreateActivityLogInput {
|
|||||||
action: ActivityAction;
|
action: ActivityAction;
|
||||||
entityType: EntityType;
|
entityType: EntityType;
|
||||||
entityId: string;
|
entityId: string;
|
||||||
details?: Record<string, any>;
|
details?: Prisma.JsonValue;
|
||||||
ipAddress?: string;
|
ipAddress?: string;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { ActivityModule } from "./activity/activity.module";
|
|||||||
import { TasksModule } from "./tasks/tasks.module";
|
import { TasksModule } from "./tasks/tasks.module";
|
||||||
import { EventsModule } from "./events/events.module";
|
import { EventsModule } from "./events/events.module";
|
||||||
import { ProjectsModule } from "./projects/projects.module";
|
import { ProjectsModule } from "./projects/projects.module";
|
||||||
|
import { DomainsModule } from "./domains/domains.module";
|
||||||
|
import { IdeasModule } from "./ideas/ideas.module";
|
||||||
|
import { WidgetsModule } from "./widgets/widgets.module";
|
||||||
|
import { LayoutsModule } from "./layouts/layouts.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -18,6 +22,10 @@ import { ProjectsModule } from "./projects/projects.module";
|
|||||||
TasksModule,
|
TasksModule,
|
||||||
EventsModule,
|
EventsModule,
|
||||||
ProjectsModule,
|
ProjectsModule,
|
||||||
|
DomainsModule,
|
||||||
|
IdeasModule,
|
||||||
|
WidgetsModule,
|
||||||
|
LayoutsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
104
apps/api/src/domains/domains.controller.ts
Normal file
104
apps/api/src/domains/domains.controller.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { DomainsService } from "./domains.service";
|
||||||
|
import { CreateDomainDto, UpdateDomainDto, QueryDomainsDto } from "./dto";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for domain endpoints
|
||||||
|
* All endpoints require authentication
|
||||||
|
*/
|
||||||
|
@Controller("domains")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class DomainsController {
|
||||||
|
constructor(private readonly domainsService: DomainsService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/domains
|
||||||
|
* Create a new domain
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
async create(@Body() createDomainDto: CreateDomainDto, @Request() req: any) {
|
||||||
|
const workspaceId = req.user?.workspaceId;
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
if (!workspaceId || !userId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.domainsService.create(workspaceId, userId, createDomainDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/domains
|
||||||
|
* Get paginated domains with optional filters
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
async findAll(@Query() query: QueryDomainsDto, @Request() req: any) {
|
||||||
|
const workspaceId = req.user?.workspaceId;
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
return this.domainsService.findAll({ ...query, workspaceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/domains/:id
|
||||||
|
* Get a single domain by ID
|
||||||
|
*/
|
||||||
|
@Get(":id")
|
||||||
|
async findOne(@Param("id") id: string, @Request() req: any) {
|
||||||
|
const workspaceId = req.user?.workspaceId;
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
return this.domainsService.findOne(id, workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/domains/:id
|
||||||
|
* Update a domain
|
||||||
|
*/
|
||||||
|
@Patch(":id")
|
||||||
|
async update(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() updateDomainDto: UpdateDomainDto,
|
||||||
|
@Request() req: any
|
||||||
|
) {
|
||||||
|
const workspaceId = req.user?.workspaceId;
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
if (!workspaceId || !userId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.domainsService.update(id, workspaceId, userId, updateDomainDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/domains/:id
|
||||||
|
* Delete a domain
|
||||||
|
*/
|
||||||
|
@Delete(":id")
|
||||||
|
async remove(@Param("id") id: string, @Request() req: any) {
|
||||||
|
const workspaceId = req.user?.workspaceId;
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
if (!workspaceId || !userId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.domainsService.remove(id, workspaceId, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/api/src/domains/domains.module.ts
Normal file
14
apps/api/src/domains/domains.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { DomainsController } from "./domains.controller";
|
||||||
|
import { DomainsService } from "./domains.service";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
import { ActivityModule } from "../activity/activity.module";
|
||||||
|
import { AuthModule } from "../auth/auth.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, ActivityModule, AuthModule],
|
||||||
|
controllers: [DomainsController],
|
||||||
|
providers: [DomainsService],
|
||||||
|
exports: [DomainsService],
|
||||||
|
})
|
||||||
|
export class DomainsModule {}
|
||||||
199
apps/api/src/domains/domains.service.ts
Normal file
199
apps/api/src/domains/domains.service.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { ActivityService } from "../activity/activity.service";
|
||||||
|
import type { CreateDomainDto, UpdateDomainDto, QueryDomainsDto } from "./dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for managing domains
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DomainsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly activityService: ActivityService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new domain
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
createDomainDto: CreateDomainDto
|
||||||
|
) {
|
||||||
|
const domain = await this.prisma.domain.create({
|
||||||
|
data: {
|
||||||
|
...createDomainDto,
|
||||||
|
workspaceId,
|
||||||
|
metadata: (createDomainDto.metadata || {}) as unknown as Prisma.InputJsonValue,
|
||||||
|
sortOrder: 0, // Default to 0, consistent with other services
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { tasks: true, events: true, projects: true, ideas: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await this.activityService.logDomainCreated(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
domain.id,
|
||||||
|
{
|
||||||
|
name: domain.name,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get paginated domains with filters
|
||||||
|
*/
|
||||||
|
async findAll(query: QueryDomainsDto) {
|
||||||
|
const page = query.page || 1;
|
||||||
|
const limit = query.limit || 50;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Build where clause
|
||||||
|
const where: any = {
|
||||||
|
workspaceId: query.workspaceId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add search filter if provided
|
||||||
|
if (query.search) {
|
||||||
|
where.OR = [
|
||||||
|
{ name: { contains: query.search, mode: "insensitive" } },
|
||||||
|
{ description: { contains: query.search, mode: "insensitive" } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute queries in parallel
|
||||||
|
const [data, total] = await Promise.all([
|
||||||
|
this.prisma.domain.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { tasks: true, events: true, projects: true, ideas: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
sortOrder: "asc",
|
||||||
|
},
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
this.prisma.domain.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
meta: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single domain by ID
|
||||||
|
*/
|
||||||
|
async findOne(id: string, workspaceId: string) {
|
||||||
|
const domain = await this.prisma.domain.findUnique({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { tasks: true, events: true, projects: true, ideas: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
throw new NotFoundException(`Domain with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a domain
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
updateDomainDto: UpdateDomainDto
|
||||||
|
) {
|
||||||
|
// Verify domain exists
|
||||||
|
const existingDomain = await this.prisma.domain.findUnique({
|
||||||
|
where: { id, workspaceId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingDomain) {
|
||||||
|
throw new NotFoundException(`Domain with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = await this.prisma.domain.update({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
data: updateDomainDto as any,
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { tasks: true, events: true, projects: true, ideas: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await this.activityService.logDomainUpdated(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
changes: updateDomainDto as Prisma.JsonValue,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a domain
|
||||||
|
*/
|
||||||
|
async remove(id: string, workspaceId: string, userId: string) {
|
||||||
|
// Verify domain exists
|
||||||
|
const domain = await this.prisma.domain.findUnique({
|
||||||
|
where: { id, workspaceId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
throw new NotFoundException(`Domain with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.domain.delete({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await this.activityService.logDomainDeleted(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
name: domain.name,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
apps/api/src/domains/dto/create-domain.dto.ts
Normal file
47
apps/api/src/domains/dto/create-domain.dto.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Matches,
|
||||||
|
IsObject,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for creating a new domain
|
||||||
|
*/
|
||||||
|
export class CreateDomainDto {
|
||||||
|
@IsString({ message: "name must be a string" })
|
||||||
|
@MinLength(1, { message: "name must not be empty" })
|
||||||
|
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString({ message: "slug must be a string" })
|
||||||
|
@MinLength(1, { message: "slug must not be empty" })
|
||||||
|
@MaxLength(100, { message: "slug must not exceed 100 characters" })
|
||||||
|
@Matches(/^[a-z0-9-]+$/, {
|
||||||
|
message: "slug must contain only lowercase letters, numbers, and hyphens",
|
||||||
|
})
|
||||||
|
slug!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "description must be a string" })
|
||||||
|
@MaxLength(10000, { message: "description must not exceed 10000 characters" })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "color must be a string" })
|
||||||
|
@Matches(/^#[0-9A-F]{6}$/i, {
|
||||||
|
message: "color must be a valid hex color code (e.g., #FF5733)",
|
||||||
|
})
|
||||||
|
color?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "icon must be a string" })
|
||||||
|
@MaxLength(50, { message: "icon must not exceed 50 characters" })
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject({ message: "metadata must be an object" })
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
3
apps/api/src/domains/dto/index.ts
Normal file
3
apps/api/src/domains/dto/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { CreateDomainDto } from "./create-domain.dto";
|
||||||
|
export { UpdateDomainDto } from "./update-domain.dto";
|
||||||
|
export { QueryDomainsDto } from "./query-domains.dto";
|
||||||
34
apps/api/src/domains/dto/query-domains.dto.ts
Normal file
34
apps/api/src/domains/dto/query-domains.dto.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
IsUUID,
|
||||||
|
IsOptional,
|
||||||
|
IsInt,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
IsString,
|
||||||
|
} from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for querying domains with filters and pagination
|
||||||
|
*/
|
||||||
|
export class QueryDomainsDto {
|
||||||
|
@IsUUID("4", { message: "workspaceId must be a valid UUID" })
|
||||||
|
workspaceId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "search must be a string" })
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: "page must be an integer" })
|
||||||
|
@Min(1, { message: "page must be at least 1" })
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: "limit must be an integer" })
|
||||||
|
@Min(1, { message: "limit must be at least 1" })
|
||||||
|
@Max(100, { message: "limit must not exceed 100" })
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
50
apps/api/src/domains/dto/update-domain.dto.ts
Normal file
50
apps/api/src/domains/dto/update-domain.dto.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Matches,
|
||||||
|
IsObject,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for updating an existing domain
|
||||||
|
* All fields are optional to support partial updates
|
||||||
|
*/
|
||||||
|
export class UpdateDomainDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "name must be a string" })
|
||||||
|
@MinLength(1, { message: "name must not be empty" })
|
||||||
|
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "slug must be a string" })
|
||||||
|
@MinLength(1, { message: "slug must not be empty" })
|
||||||
|
@MaxLength(100, { message: "slug must not exceed 100 characters" })
|
||||||
|
@Matches(/^[a-z0-9-]+$/, {
|
||||||
|
message: "slug must contain only lowercase letters, numbers, and hyphens",
|
||||||
|
})
|
||||||
|
slug?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "description must be a string" })
|
||||||
|
@MaxLength(10000, { message: "description must not exceed 10000 characters" })
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "color must be a string" })
|
||||||
|
@Matches(/^#[0-9A-F]{6}$/i, {
|
||||||
|
message: "color must be a valid hex color code (e.g., #FF5733)",
|
||||||
|
})
|
||||||
|
color?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "icon must be a string" })
|
||||||
|
@MaxLength(50, { message: "icon must not exceed 50 characters" })
|
||||||
|
icon?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject({ message: "metadata must be an object" })
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ export class UpdateEventDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString({ message: "description must be a string" })
|
@IsString({ message: "description must be a string" })
|
||||||
@MaxLength(10000, { message: "description must not exceed 10000 characters" })
|
@MaxLength(10000, { message: "description must not exceed 10000 characters" })
|
||||||
description?: string | null;
|
description?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString({}, { message: "startTime must be a valid ISO 8601 date string" })
|
@IsDateString({}, { message: "startTime must be a valid ISO 8601 date string" })
|
||||||
@@ -31,7 +31,7 @@ export class UpdateEventDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString({}, { message: "endTime must be a valid ISO 8601 date string" })
|
@IsDateString({}, { message: "endTime must be a valid ISO 8601 date string" })
|
||||||
endTime?: Date | null;
|
endTime?: Date;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean({ message: "allDay must be a boolean" })
|
@IsBoolean({ message: "allDay must be a boolean" })
|
||||||
@@ -40,15 +40,15 @@ export class UpdateEventDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString({ message: "location must be a string" })
|
@IsString({ message: "location must be a string" })
|
||||||
@MaxLength(500, { message: "location must not exceed 500 characters" })
|
@MaxLength(500, { message: "location must not exceed 500 characters" })
|
||||||
location?: string | null;
|
location?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsObject({ message: "recurrence must be an object" })
|
@IsObject({ message: "recurrence must be an object" })
|
||||||
recurrence?: Record<string, unknown> | null;
|
recurrence?: Record<string, unknown>;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID("4", { message: "projectId must be a valid UUID" })
|
@IsUUID("4", { message: "projectId must be a valid UUID" })
|
||||||
projectId?: string | null;
|
projectId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsObject({ message: "metadata must be an object" })
|
@IsObject({ message: "metadata must be an object" })
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { ActivityService } from "../activity/activity.service";
|
import { ActivityService } from "../activity/activity.service";
|
||||||
import type { CreateEventDto, UpdateEventDto, QueryEventsDto } from "./dto";
|
import type { CreateEventDto, UpdateEventDto, QueryEventsDto } from "./dto";
|
||||||
@@ -157,7 +158,7 @@ export class EventsService {
|
|||||||
id,
|
id,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
},
|
},
|
||||||
data: updateEventDto,
|
data: updateEventDto as any,
|
||||||
include: {
|
include: {
|
||||||
creator: {
|
creator: {
|
||||||
select: { id: true, name: true, email: true },
|
select: { id: true, name: true, email: true },
|
||||||
@@ -170,7 +171,7 @@ export class EventsService {
|
|||||||
|
|
||||||
// Log activity
|
// Log activity
|
||||||
await this.activityService.logEventUpdated(workspaceId, userId, id, {
|
await this.activityService.logEventUpdated(workspaceId, userId, id, {
|
||||||
changes: updateEventDto,
|
changes: updateEventDto as Prisma.JsonValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
return event;
|
return event;
|
||||||
|
|||||||
22
apps/api/src/ideas/dto/capture-idea.dto.ts
Normal file
22
apps/api/src/ideas/dto/capture-idea.dto.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for quick capturing ideas with minimal fields
|
||||||
|
* Intended for rapid idea capture without complex categorization
|
||||||
|
*/
|
||||||
|
export class CaptureIdeaDto {
|
||||||
|
@IsString({ message: "content must be a string" })
|
||||||
|
@MinLength(1, { message: "content must not be empty" })
|
||||||
|
@MaxLength(50000, { message: "content must not exceed 50000 characters" })
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "title must be a string" })
|
||||||
|
@MaxLength(500, { message: "title must not exceed 500 characters" })
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
56
apps/api/src/ideas/dto/create-idea.dto.ts
Normal file
56
apps/api/src/ideas/dto/create-idea.dto.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { IdeaStatus, TaskPriority } from "@prisma/client";
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsArray,
|
||||||
|
IsUUID,
|
||||||
|
IsObject,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for creating a new idea
|
||||||
|
*/
|
||||||
|
export class CreateIdeaDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "title must be a string" })
|
||||||
|
@MaxLength(500, { message: "title must not exceed 500 characters" })
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsString({ message: "content must be a string" })
|
||||||
|
@MinLength(1, { message: "content must not be empty" })
|
||||||
|
@MaxLength(50000, { message: "content must not exceed 50000 characters" })
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID("4", { message: "domainId must be a valid UUID" })
|
||||||
|
domainId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID("4", { message: "projectId must be a valid UUID" })
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(IdeaStatus, { message: "status must be a valid IdeaStatus" })
|
||||||
|
status?: IdeaStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(TaskPriority, { message: "priority must be a valid TaskPriority" })
|
||||||
|
priority?: TaskPriority;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "category must be a string" })
|
||||||
|
@MaxLength(100, { message: "category must not exceed 100 characters" })
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray({ message: "tags must be an array" })
|
||||||
|
@IsString({ each: true, message: "each tag must be a string" })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject({ message: "metadata must be an object" })
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
4
apps/api/src/ideas/dto/index.ts
Normal file
4
apps/api/src/ideas/dto/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { CreateIdeaDto } from "./create-idea.dto";
|
||||||
|
export { CaptureIdeaDto } from "./capture-idea.dto";
|
||||||
|
export { UpdateIdeaDto } from "./update-idea.dto";
|
||||||
|
export { QueryIdeasDto } from "./query-ideas.dto";
|
||||||
52
apps/api/src/ideas/dto/query-ideas.dto.ts
Normal file
52
apps/api/src/ideas/dto/query-ideas.dto.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { IdeaStatus } from "@prisma/client";
|
||||||
|
import {
|
||||||
|
IsUUID,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
IsString,
|
||||||
|
} from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for querying ideas with filters and pagination
|
||||||
|
*/
|
||||||
|
export class QueryIdeasDto {
|
||||||
|
@IsUUID("4", { message: "workspaceId must be a valid UUID" })
|
||||||
|
workspaceId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(IdeaStatus, { message: "status must be a valid IdeaStatus" })
|
||||||
|
status?: IdeaStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID("4", { message: "domainId must be a valid UUID" })
|
||||||
|
domainId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID("4", { message: "projectId must be a valid UUID" })
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "category must be a string" })
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "search must be a string" })
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: "page must be an integer" })
|
||||||
|
@Min(1, { message: "page must be at least 1" })
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: "limit must be an integer" })
|
||||||
|
@Min(1, { message: "limit must be at least 1" })
|
||||||
|
@Max(100, { message: "limit must not exceed 100" })
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
58
apps/api/src/ideas/dto/update-idea.dto.ts
Normal file
58
apps/api/src/ideas/dto/update-idea.dto.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { IdeaStatus, TaskPriority } from "@prisma/client";
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsArray,
|
||||||
|
IsUUID,
|
||||||
|
IsObject,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for updating an existing idea
|
||||||
|
* All fields are optional to support partial updates
|
||||||
|
*/
|
||||||
|
export class UpdateIdeaDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "title must be a string" })
|
||||||
|
@MaxLength(500, { message: "title must not exceed 500 characters" })
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "content must be a string" })
|
||||||
|
@MinLength(1, { message: "content must not be empty" })
|
||||||
|
@MaxLength(50000, { message: "content must not exceed 50000 characters" })
|
||||||
|
content?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID("4", { message: "domainId must be a valid UUID" })
|
||||||
|
domainId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID("4", { message: "projectId must be a valid UUID" })
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(IdeaStatus, { message: "status must be a valid IdeaStatus" })
|
||||||
|
status?: IdeaStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(TaskPriority, { message: "priority must be a valid TaskPriority" })
|
||||||
|
priority?: TaskPriority;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "category must be a string" })
|
||||||
|
@MaxLength(100, { message: "category must not exceed 100 characters" })
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray({ message: "tags must be an array" })
|
||||||
|
@IsString({ each: true, message: "each tag must be a string" })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject({ message: "metadata must be an object" })
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
130
apps/api/src/ideas/ideas.controller.ts
Normal file
130
apps/api/src/ideas/ideas.controller.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { IdeasService } from "./ideas.service";
|
||||||
|
import {
|
||||||
|
CreateIdeaDto,
|
||||||
|
CaptureIdeaDto,
|
||||||
|
UpdateIdeaDto,
|
||||||
|
QueryIdeasDto,
|
||||||
|
} from "./dto";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for idea endpoints
|
||||||
|
* All endpoints require authentication
|
||||||
|
*/
|
||||||
|
@Controller("ideas")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class IdeasController {
|
||||||
|
constructor(private readonly ideasService: IdeasService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ideas/capture
|
||||||
|
* Quick capture endpoint for rapid idea capture
|
||||||
|
* Requires minimal fields: content only (title optional)
|
||||||
|
*/
|
||||||
|
@Post("capture")
|
||||||
|
async capture(
|
||||||
|
@Body() captureIdeaDto: CaptureIdeaDto,
|
||||||
|
@Request() req: any
|
||||||
|
) {
|
||||||
|
const workspaceId = req.user?.workspaceId;
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
if (!workspaceId || !userId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.ideasService.capture(workspaceId, userId, captureIdeaDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ideas
|
||||||
|
* Create a new idea with full categorization options
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
async create(@Body() createIdeaDto: CreateIdeaDto, @Request() req: any) {
|
||||||
|
const workspaceId = req.user?.workspaceId;
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
if (!workspaceId || !userId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.ideasService.create(workspaceId, userId, createIdeaDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ideas
|
||||||
|
* Get paginated ideas with optional filters
|
||||||
|
* Supports status, domain, project, category, and search filters
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
async findAll(@Query() query: QueryIdeasDto, @Request() req: any) {
|
||||||
|
const workspaceId = req.user?.workspaceId;
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
return this.ideasService.findAll({ ...query, workspaceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ideas/:id
|
||||||
|
* Get a single idea by ID
|
||||||
|
*/
|
||||||
|
@Get(":id")
|
||||||
|
async findOne(@Param("id") id: string, @Request() req: any) {
|
||||||
|
const workspaceId = req.user?.workspaceId;
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
return this.ideasService.findOne(id, workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/ideas/:id
|
||||||
|
* Update an idea
|
||||||
|
*/
|
||||||
|
@Patch(":id")
|
||||||
|
async update(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() updateIdeaDto: UpdateIdeaDto,
|
||||||
|
@Request() req: any
|
||||||
|
) {
|
||||||
|
const workspaceId = req.user?.workspaceId;
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
if (!workspaceId || !userId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.ideasService.update(id, workspaceId, userId, updateIdeaDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/ideas/:id
|
||||||
|
* Delete an idea
|
||||||
|
*/
|
||||||
|
@Delete(":id")
|
||||||
|
async remove(@Param("id") id: string, @Request() req: any) {
|
||||||
|
const workspaceId = req.user?.workspaceId;
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
if (!workspaceId || !userId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.ideasService.remove(id, workspaceId, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/api/src/ideas/ideas.module.ts
Normal file
14
apps/api/src/ideas/ideas.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { IdeasController } from "./ideas.controller";
|
||||||
|
import { IdeasService } from "./ideas.service";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
import { ActivityModule } from "../activity/activity.module";
|
||||||
|
import { AuthModule } from "../auth/auth.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, ActivityModule, AuthModule],
|
||||||
|
controllers: [IdeasController],
|
||||||
|
providers: [IdeasService],
|
||||||
|
exports: [IdeasService],
|
||||||
|
})
|
||||||
|
export class IdeasModule {}
|
||||||
293
apps/api/src/ideas/ideas.service.ts
Normal file
293
apps/api/src/ideas/ideas.service.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { ActivityService } from "../activity/activity.service";
|
||||||
|
import { IdeaStatus } from "@prisma/client";
|
||||||
|
import type {
|
||||||
|
CreateIdeaDto,
|
||||||
|
CaptureIdeaDto,
|
||||||
|
UpdateIdeaDto,
|
||||||
|
QueryIdeasDto,
|
||||||
|
} from "./dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for managing ideas
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class IdeasService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly activityService: ActivityService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new idea
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
createIdeaDto: CreateIdeaDto
|
||||||
|
) {
|
||||||
|
const data: any = {
|
||||||
|
...createIdeaDto,
|
||||||
|
workspaceId,
|
||||||
|
creatorId: userId,
|
||||||
|
status: createIdeaDto.status || IdeaStatus.CAPTURED,
|
||||||
|
priority: createIdeaDto.priority || "MEDIUM",
|
||||||
|
tags: createIdeaDto.tags || [],
|
||||||
|
metadata: createIdeaDto.metadata || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const idea = await this.prisma.idea.create({
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
},
|
||||||
|
domain: {
|
||||||
|
select: { id: true, name: true, color: true },
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
select: { id: true, name: true, color: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await this.activityService.logIdeaCreated(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
idea.id,
|
||||||
|
{
|
||||||
|
title: idea.title || "Untitled",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return idea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick capture - create an idea with minimal fields
|
||||||
|
* Optimized for rapid idea capture from the front-end
|
||||||
|
*/
|
||||||
|
async capture(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
captureIdeaDto: CaptureIdeaDto
|
||||||
|
) {
|
||||||
|
const data: any = {
|
||||||
|
workspaceId,
|
||||||
|
creatorId: userId,
|
||||||
|
content: captureIdeaDto.content,
|
||||||
|
title: captureIdeaDto.title,
|
||||||
|
status: IdeaStatus.CAPTURED,
|
||||||
|
priority: "MEDIUM",
|
||||||
|
tags: [],
|
||||||
|
metadata: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const idea = await this.prisma.idea.create({
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await this.activityService.logIdeaCreated(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
idea.id,
|
||||||
|
{
|
||||||
|
quickCapture: true,
|
||||||
|
title: idea.title || "Untitled",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return idea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get paginated ideas with filters
|
||||||
|
*/
|
||||||
|
async findAll(query: QueryIdeasDto) {
|
||||||
|
const page = query.page || 1;
|
||||||
|
const limit = query.limit || 50;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Build where clause
|
||||||
|
const where: any = {
|
||||||
|
workspaceId: query.workspaceId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (query.status) {
|
||||||
|
where.status = query.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.domainId) {
|
||||||
|
where.domainId = query.domainId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.projectId) {
|
||||||
|
where.projectId = query.projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.category) {
|
||||||
|
where.category = query.category;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add search filter if provided
|
||||||
|
if (query.search) {
|
||||||
|
where.OR = [
|
||||||
|
{ title: { contains: query.search, mode: "insensitive" } },
|
||||||
|
{ content: { contains: query.search, mode: "insensitive" } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute queries in parallel
|
||||||
|
const [data, total] = await Promise.all([
|
||||||
|
this.prisma.idea.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
},
|
||||||
|
domain: {
|
||||||
|
select: { id: true, name: true, color: true },
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
select: { id: true, name: true, color: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
this.prisma.idea.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
meta: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single idea by ID
|
||||||
|
*/
|
||||||
|
async findOne(id: string, workspaceId: string) {
|
||||||
|
const idea = await this.prisma.idea.findUnique({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
},
|
||||||
|
domain: {
|
||||||
|
select: { id: true, name: true, color: true },
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
select: { id: true, name: true, color: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!idea) {
|
||||||
|
throw new NotFoundException(`Idea with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return idea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an idea
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
updateIdeaDto: UpdateIdeaDto
|
||||||
|
) {
|
||||||
|
// Verify idea exists
|
||||||
|
const existingIdea = await this.prisma.idea.findUnique({
|
||||||
|
where: { id, workspaceId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingIdea) {
|
||||||
|
throw new NotFoundException(`Idea with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const idea = await this.prisma.idea.update({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
data: updateIdeaDto as any,
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
},
|
||||||
|
domain: {
|
||||||
|
select: { id: true, name: true, color: true },
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
select: { id: true, name: true, color: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await this.activityService.logIdeaUpdated(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
changes: updateIdeaDto as Prisma.JsonValue,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return idea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an idea
|
||||||
|
*/
|
||||||
|
async remove(id: string, workspaceId: string, userId: string) {
|
||||||
|
// Verify idea exists
|
||||||
|
const idea = await this.prisma.idea.findUnique({
|
||||||
|
where: { id, workspaceId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!idea) {
|
||||||
|
throw new NotFoundException(`Idea with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.idea.delete({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await this.activityService.logIdeaDeleted(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
title: idea.title || "Untitled",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apps/api/src/layouts/dto/create-layout.dto.ts
Normal file
31
apps/api/src/layouts/dto/create-layout.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
IsArray,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
IsObject,
|
||||||
|
} from "class-validator";
|
||||||
|
import { Layout } from "../types/widget.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for creating a new user layout
|
||||||
|
*/
|
||||||
|
export class CreateLayoutDto {
|
||||||
|
@IsString({ message: "name must be a string" })
|
||||||
|
@MinLength(1, { message: "name must not be empty" })
|
||||||
|
@MaxLength(100, { message: "name must not exceed 100 characters" })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean({ message: "isDefault must be a boolean" })
|
||||||
|
isDefault?: boolean;
|
||||||
|
|
||||||
|
@IsArray({ message: "layout must be an array" })
|
||||||
|
layout!: Layout;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject({ message: "metadata must be an object" })
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
2
apps/api/src/layouts/dto/index.ts
Normal file
2
apps/api/src/layouts/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { CreateLayoutDto } from "./create-layout.dto";
|
||||||
|
export { UpdateLayoutDto } from "./update-layout.dto";
|
||||||
34
apps/api/src/layouts/dto/update-layout.dto.ts
Normal file
34
apps/api/src/layouts/dto/update-layout.dto.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
IsArray,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
IsObject,
|
||||||
|
} from "class-validator";
|
||||||
|
import { Layout } from "../types/widget.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for updating an existing user layout
|
||||||
|
* All fields are optional to support partial updates
|
||||||
|
*/
|
||||||
|
export class UpdateLayoutDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "name must be a string" })
|
||||||
|
@MinLength(1, { message: "name must not be empty" })
|
||||||
|
@MaxLength(100, { message: "name must not exceed 100 characters" })
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean({ message: "isDefault must be a boolean" })
|
||||||
|
isDefault?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray({ message: "layout must be an array" })
|
||||||
|
layout?: Layout;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject({ message: "metadata must be an object" })
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
128
apps/api/src/layouts/layouts.controller.ts
Normal file
128
apps/api/src/layouts/layouts.controller.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { LayoutsService } from "./layouts.service";
|
||||||
|
import { CreateLayoutDto, UpdateLayoutDto } from "./dto";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for user layout endpoints
|
||||||
|
* All endpoints require authentication
|
||||||
|
*/
|
||||||
|
@Controller("layouts")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class LayoutsController {
|
||||||
|
constructor(private readonly layoutsService: LayoutsService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/layouts
|
||||||
|
* Get all layouts for the authenticated user
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
async findAll(@Request() req: any) {
|
||||||
|
const workspaceId = req.user?.workspaceId;
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
if (!workspaceId || !userId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.layoutsService.findAll(workspaceId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/layouts/:id
|
||||||
|
* Get a single layout by ID
|
||||||
|
*/
|
||||||
|
@Get(":id")
|
||||||
|
async findOne(@Param("id") id: string, @Request() req: any) {
|
||||||
|
const workspaceId = req.user?.workspaceId;
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
if (!workspaceId || !userId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.layoutsService.findOne(id, workspaceId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/layouts/default
|
||||||
|
* Get the default layout for the authenticated user
|
||||||
|
* Falls back to the most recently created layout if no default exists
|
||||||
|
*/
|
||||||
|
@Get("default")
|
||||||
|
async findDefault(@Request() req: any) {
|
||||||
|
const workspaceId = req.user?.workspaceId;
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
if (!workspaceId || !userId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.layoutsService.findDefault(workspaceId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/layouts
|
||||||
|
* Create a new layout
|
||||||
|
* If isDefault is true, any existing default layout will be unset
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
async create(@Body() createLayoutDto: CreateLayoutDto, @Request() req: any) {
|
||||||
|
const workspaceId = req.user?.workspaceId;
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
if (!workspaceId || !userId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.layoutsService.create(workspaceId, userId, createLayoutDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/layouts/:id
|
||||||
|
* Update a layout
|
||||||
|
* If isDefault is set to true, any existing default layout will be unset
|
||||||
|
*/
|
||||||
|
@Patch(":id")
|
||||||
|
async update(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() updateLayoutDto: UpdateLayoutDto,
|
||||||
|
@Request() req: any
|
||||||
|
) {
|
||||||
|
const workspaceId = req.user?.workspaceId;
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
if (!workspaceId || !userId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.layoutsService.update(id, workspaceId, userId, updateLayoutDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/layouts/:id
|
||||||
|
* Delete a layout
|
||||||
|
*/
|
||||||
|
@Delete(":id")
|
||||||
|
async remove(@Param("id") id: string, @Request() req: any) {
|
||||||
|
const workspaceId = req.user?.workspaceId;
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
if (!workspaceId || !userId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.layoutsService.remove(id, workspaceId, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/layouts/layouts.module.ts
Normal file
13
apps/api/src/layouts/layouts.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { LayoutsController } from "./layouts.controller";
|
||||||
|
import { LayoutsService } from "./layouts.service";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
import { AuthModule } from "../auth/auth.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, AuthModule],
|
||||||
|
controllers: [LayoutsController],
|
||||||
|
providers: [LayoutsService],
|
||||||
|
exports: [LayoutsService],
|
||||||
|
})
|
||||||
|
export class LayoutsModule {}
|
||||||
185
apps/api/src/layouts/layouts.service.ts
Normal file
185
apps/api/src/layouts/layouts.service.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import type { CreateLayoutDto, UpdateLayoutDto } from "./dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for managing user layouts
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class LayoutsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all layouts for a user
|
||||||
|
*/
|
||||||
|
async findAll(workspaceId: string, userId: string) {
|
||||||
|
return this.prisma.userLayout.findMany({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
isDefault: "desc",
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default layout for a user
|
||||||
|
*/
|
||||||
|
async findDefault(workspaceId: string, userId: string) {
|
||||||
|
const layout = await this.prisma.userLayout.findFirst({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no default layout exists, return the most recently created one
|
||||||
|
if (!layout) {
|
||||||
|
const recentLayout = await this.prisma.userLayout.findFirst({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!recentLayout) {
|
||||||
|
throw new NotFoundException(`No layouts found for this user`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return recentLayout;
|
||||||
|
}
|
||||||
|
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single layout by ID
|
||||||
|
*/
|
||||||
|
async findOne(id: string, workspaceId: string, userId: string) {
|
||||||
|
const layout = await this.prisma.userLayout.findUnique({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!layout) {
|
||||||
|
throw new NotFoundException(`Layout with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new layout
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
createLayoutDto: CreateLayoutDto
|
||||||
|
) {
|
||||||
|
// Use transaction to ensure atomicity when setting default
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
// If setting as default, unset other defaults first
|
||||||
|
if (createLayoutDto.isDefault) {
|
||||||
|
await tx.userLayout.updateMany({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.userLayout.create({
|
||||||
|
data: {
|
||||||
|
...createLayoutDto,
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
isDefault: createLayoutDto.isDefault || false,
|
||||||
|
layout: (createLayoutDto.layout || []) as unknown as Prisma.JsonValue,
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a layout
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
updateLayoutDto: UpdateLayoutDto
|
||||||
|
) {
|
||||||
|
// Use transaction to ensure atomicity when setting default
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
// Verify layout exists
|
||||||
|
const existingLayout = await tx.userLayout.findUnique({
|
||||||
|
where: { id, workspaceId, userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingLayout) {
|
||||||
|
throw new NotFoundException(`Layout with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If setting as default, unset other defaults first
|
||||||
|
if (updateLayoutDto.isDefault === true) {
|
||||||
|
await tx.userLayout.updateMany({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
id: { not: id },
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.userLayout.update({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
data: updateLayoutDto as any,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a layout
|
||||||
|
*/
|
||||||
|
async remove(id: string, workspaceId: string, userId: string) {
|
||||||
|
// Verify layout exists
|
||||||
|
const layout = await this.prisma.userLayout.findUnique({
|
||||||
|
where: { id, workspaceId, userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!layout) {
|
||||||
|
throw new NotFoundException(`Layout with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.userLayout.delete({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
35
apps/api/src/layouts/types/widget.types.ts
Normal file
35
apps/api/src/layouts/types/widget.types.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Widget configuration types for user layouts
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base widget interface
|
||||||
|
*/
|
||||||
|
export interface Widget {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
minW?: number;
|
||||||
|
minH?: number;
|
||||||
|
maxW?: number;
|
||||||
|
maxH?: number;
|
||||||
|
isDraggable?: boolean;
|
||||||
|
isResizable?: boolean;
|
||||||
|
isBounded?: boolean;
|
||||||
|
static?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget with configuration
|
||||||
|
*/
|
||||||
|
export interface ConfiguredWidget extends Widget {
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout configuration type
|
||||||
|
*/
|
||||||
|
export type Layout = ConfiguredWidget[];
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { ActivityService } from "../activity/activity.service";
|
import { ActivityService } from "../activity/activity.service";
|
||||||
import { ProjectStatus } from "@prisma/client";
|
import { ProjectStatus } from "@prisma/client";
|
||||||
@@ -177,7 +178,7 @@ export class ProjectsService {
|
|||||||
id,
|
id,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
},
|
},
|
||||||
data: updateProjectDto,
|
data: updateProjectDto as any,
|
||||||
include: {
|
include: {
|
||||||
creator: {
|
creator: {
|
||||||
select: { id: true, name: true, email: true },
|
select: { id: true, name: true, email: true },
|
||||||
@@ -190,7 +191,7 @@ export class ProjectsService {
|
|||||||
|
|
||||||
// Log activity
|
// Log activity
|
||||||
await this.activityService.logProjectUpdated(workspaceId, userId, id, {
|
await this.activityService.logProjectUpdated(workspaceId, userId, id, {
|
||||||
changes: updateProjectDto,
|
changes: updateProjectDto as Prisma.JsonValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
return project;
|
return project;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { ActivityService } from "../activity/activity.service";
|
import { ActivityService } from "../activity/activity.service";
|
||||||
import { TaskStatus, TaskPriority } from "@prisma/client";
|
import { TaskStatus, TaskPriority } from "@prisma/client";
|
||||||
@@ -226,7 +227,7 @@ export class TasksService {
|
|||||||
|
|
||||||
// Log activities
|
// Log activities
|
||||||
await this.activityService.logTaskUpdated(workspaceId, userId, id, {
|
await this.activityService.logTaskUpdated(workspaceId, userId, id, {
|
||||||
changes: updateTaskDto,
|
changes: updateTaskDto as Prisma.JsonValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log completion if status changed to COMPLETED
|
// Log completion if status changed to COMPLETED
|
||||||
|
|||||||
39
apps/api/src/widgets/widgets.controller.ts
Normal file
39
apps/api/src/widgets/widgets.controller.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { WidgetsService } from "./widgets.service";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for widget definition endpoints
|
||||||
|
* All endpoints require authentication
|
||||||
|
* Provides read-only access to available widget definitions
|
||||||
|
*/
|
||||||
|
@Controller("widgets")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class WidgetsController {
|
||||||
|
constructor(private readonly widgetsService: WidgetsService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/widgets
|
||||||
|
* List all available widget definitions
|
||||||
|
* Returns only active widgets
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
async findAll() {
|
||||||
|
return this.widgetsService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/widgets/:name
|
||||||
|
* Get a widget definition by name
|
||||||
|
* Useful for fetching widget configuration schemas
|
||||||
|
*/
|
||||||
|
@Get(":name")
|
||||||
|
async findByName(@Param("name") name: string) {
|
||||||
|
return this.widgetsService.findByName(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/widgets/widgets.module.ts
Normal file
13
apps/api/src/widgets/widgets.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { WidgetsController } from "./widgets.controller";
|
||||||
|
import { WidgetsService } from "./widgets.service";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
import { AuthModule } from "../auth/auth.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, AuthModule],
|
||||||
|
controllers: [WidgetsController],
|
||||||
|
providers: [WidgetsService],
|
||||||
|
exports: [WidgetsService],
|
||||||
|
})
|
||||||
|
export class WidgetsModule {}
|
||||||
59
apps/api/src/widgets/widgets.service.ts
Normal file
59
apps/api/src/widgets/widgets.service.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for managing widget definitions
|
||||||
|
* Provides read-only access to available widget definitions
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class WidgetsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active widget definitions
|
||||||
|
*/
|
||||||
|
async findAll() {
|
||||||
|
return this.prisma.widgetDefinition.findMany({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
name: "asc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a widget definition by name
|
||||||
|
*/
|
||||||
|
async findByName(name: string) {
|
||||||
|
const widget = await this.prisma.widgetDefinition.findUnique({
|
||||||
|
where: {
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!widget) {
|
||||||
|
throw new NotFoundException(`Widget definition with name '${name}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return widget;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a widget definition by ID
|
||||||
|
*/
|
||||||
|
async findOne(id: string) {
|
||||||
|
const widget = await this.prisma.widgetDefinition.findUnique({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!widget) {
|
||||||
|
throw new NotFoundException(`Widget definition with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return widget;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,9 +19,11 @@
|
|||||||
"@mosaic/ui": "workspace:*",
|
"@mosaic/ui": "workspace:*",
|
||||||
"@tanstack/react-query": "^5.90.20",
|
"@tanstack/react-query": "^5.90.20",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"react-grid-layout": "^2.2.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@mosaic/config": "workspace:*",
|
"@mosaic/config": "workspace:*",
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
"@types/node": "^22.13.4",
|
"@types/node": "^22.13.4",
|
||||||
"@types/react": "^19.0.8",
|
"@types/react": "^19.0.8",
|
||||||
"@types/react-dom": "^19.0.3",
|
"@types/react-dom": "^19.0.3",
|
||||||
|
"@types/react-grid-layout": "^2.1.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
|
|||||||
48
apps/web/src/app/(authenticated)/page.tsx
Normal file
48
apps/web/src/app/(authenticated)/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { RecentTasksWidget } from "@/components/dashboard/RecentTasksWidget";
|
||||||
|
import { UpcomingEventsWidget } from "@/components/dashboard/UpcomingEventsWidget";
|
||||||
|
import { QuickCaptureWidget } from "@/components/dashboard/QuickCaptureWidget";
|
||||||
|
import { DomainOverviewWidget } from "@/components/dashboard/DomainOverviewWidget";
|
||||||
|
import { mockTasks } from "@/lib/api/tasks";
|
||||||
|
import { mockEvents } from "@/lib/api/events";
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
// TODO: Replace with real API call when backend is ready
|
||||||
|
// const { data: tasks, isLoading: tasksLoading } = useQuery({
|
||||||
|
// queryKey: ["tasks"],
|
||||||
|
// queryFn: fetchTasks,
|
||||||
|
// });
|
||||||
|
// const { data: events, isLoading: eventsLoading } = useQuery({
|
||||||
|
// queryKey: ["events"],
|
||||||
|
// queryFn: fetchEvents,
|
||||||
|
// });
|
||||||
|
|
||||||
|
const tasks = mockTasks;
|
||||||
|
const events = mockEvents;
|
||||||
|
const tasksLoading = false;
|
||||||
|
const eventsLoading = false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||||
|
<p className="text-gray-600 mt-2">
|
||||||
|
Welcome back! Here's your overview
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Top row: Domain Overview and Quick Capture */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<DomainOverviewWidget tasks={tasks} isLoading={tasksLoading} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RecentTasksWidget tasks={tasks} isLoading={tasksLoading} />
|
||||||
|
<UpcomingEventsWidget events={events} isLoading={eventsLoading} />
|
||||||
|
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<QuickCaptureWidget />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
apps/web/src/components/dashboard/DomainOverviewWidget.tsx
Normal file
46
apps/web/src/components/dashboard/DomainOverviewWidget.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Task } from "@mosaic/shared";
|
||||||
|
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
||||||
|
|
||||||
|
interface DomainOverviewWidgetProps {
|
||||||
|
tasks: Task[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DomainOverviewWidget({ tasks, isLoading }: DomainOverviewWidgetProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex justify-center items-center">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
|
||||||
|
<span className="ml-3 text-gray-600">Loading overview...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: tasks.length,
|
||||||
|
inProgress: tasks.filter((t) => t.status === TaskStatus.IN_PROGRESS).length,
|
||||||
|
completed: tasks.filter((t) => t.status === TaskStatus.COMPLETED).length,
|
||||||
|
highPriority: tasks.filter((t) => t.priority === TaskPriority.HIGH).length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatCard = ({ label, value, color }: { label: string; value: number; color: string }) => (
|
||||||
|
<div className={`p-4 rounded-lg bg-gradient-to-br ${color}`}>
|
||||||
|
<div className="text-3xl font-bold text-white mb-1">{value}</div>
|
||||||
|
<div className="text-sm text-white/90">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Domain Overview</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<StatCard label="Total Tasks" value={stats.total} color="from-blue-500 to-blue-600" />
|
||||||
|
<StatCard label="In Progress" value={stats.inProgress} color="from-green-500 to-green-600" />
|
||||||
|
<StatCard label="Completed" value={stats.completed} color="from-purple-500 to-purple-600" />
|
||||||
|
<StatCard label="High Priority" value={stats.highPriority} color="from-red-500 to-red-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
apps/web/src/components/dashboard/QuickCaptureWidget.tsx
Normal file
55
apps/web/src/components/dashboard/QuickCaptureWidget.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@mosaic/ui";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export function QuickCaptureWidget() {
|
||||||
|
const [idea, setIdea] = useState("");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!idea.trim()) return;
|
||||||
|
|
||||||
|
// TODO: Implement quick capture API call
|
||||||
|
// For now, just show a success indicator
|
||||||
|
console.log("Quick capture:", idea);
|
||||||
|
setIdea("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToTasks = () => {
|
||||||
|
router.push("/tasks");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Quick Capture</h2>
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
Quickly jot down ideas or brain dumps
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<textarea
|
||||||
|
value={idea}
|
||||||
|
onChange={(e) => setIdea(e.target.value)}
|
||||||
|
placeholder="What's on your mind?"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" variant="primary" size="sm">
|
||||||
|
Save Note
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToTasks}
|
||||||
|
>
|
||||||
|
Create Task
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
apps/web/src/components/dashboard/RecentTasksWidget.tsx
Normal file
86
apps/web/src/components/dashboard/RecentTasksWidget.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { Task } from "@mosaic/shared";
|
||||||
|
import { TaskPriority } from "@mosaic/shared";
|
||||||
|
import { formatDate } from "@/lib/utils/date-format";
|
||||||
|
import { TaskStatus } from "@mosaic/shared";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface RecentTasksWidgetProps {
|
||||||
|
tasks: Task[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusIcons: Record<TaskStatus, string> = {
|
||||||
|
[TaskStatus.NOT_STARTED]: "⚪",
|
||||||
|
[TaskStatus.IN_PROGRESS]: "🟢",
|
||||||
|
[TaskStatus.PAUSED]: "⏸️",
|
||||||
|
[TaskStatus.COMPLETED]: "✅",
|
||||||
|
[TaskStatus.ARCHIVED]: "💤",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RecentTasksWidget({ tasks, isLoading }: RecentTasksWidgetProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex justify-center items-center">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
|
||||||
|
<span className="ml-3 text-gray-600">Loading tasks...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentTasks = tasks.slice(0, 5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Recent Tasks</h2>
|
||||||
|
<Link
|
||||||
|
href="/tasks"
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
View all →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{recentTasks.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-4">No tasks yet</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{recentTasks.map((task) => (
|
||||||
|
<li
|
||||||
|
key={task.id}
|
||||||
|
className="flex items-start gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-lg flex-shrink-0" aria-label={`Status: ${task.status}`}>
|
||||||
|
{statusIcons[task.status]}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-medium text-gray-900 text-sm truncate">
|
||||||
|
{task.title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
{task.priority !== TaskPriority.LOW && (
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-0.5 rounded-full ${
|
||||||
|
task.priority === TaskPriority.HIGH
|
||||||
|
? "bg-red-100 text-red-700"
|
||||||
|
: "bg-blue-100 text-blue-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{task.priority}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{task.dueDate && (
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{formatDate(task.dueDate)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
apps/web/src/components/dashboard/UpcomingEventsWidget.tsx
Normal file
66
apps/web/src/components/dashboard/UpcomingEventsWidget.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { Event } from "@mosaic/shared";
|
||||||
|
import { formatTime, formatDate } from "@/lib/utils/date-format";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface UpcomingEventsWidgetProps {
|
||||||
|
events: Event[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpcomingEventsWidget({ events, isLoading }: UpcomingEventsWidgetProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex justify-center items-center">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
|
||||||
|
<span className="ml-3 text-gray-600">Loading events...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const upcomingEvents = events.slice(0, 4);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Upcoming Events</h2>
|
||||||
|
<Link
|
||||||
|
href="/calendar"
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
View calendar →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{upcomingEvents.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-4">No upcoming events</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{upcomingEvents.map((event) => (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
className="flex items-start gap-3 p-3 rounded-lg border-l-4 border-blue-500 bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 text-center min-w-[3.5rem]">
|
||||||
|
<div className="text-xs text-gray-500 uppercase font-semibold">
|
||||||
|
{formatDate(event.startTime).split(',')[0]}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{formatTime(event.startTime)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-medium text-gray-900 text-sm truncate">
|
||||||
|
{event.title}
|
||||||
|
</h3>
|
||||||
|
{event.location && (
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">📍 {event.location}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
apps/web/src/components/hud/HUD.tsx
Normal file
182
apps/web/src/components/hud/HUD.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* HUD container - main dashboard interface
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Button } from "@mosaic/ui";
|
||||||
|
import { RotateCcw } from "lucide-react";
|
||||||
|
import { WidgetGrid } from "./WidgetGrid";
|
||||||
|
import { WidgetRenderer } from "./WidgetRenderer";
|
||||||
|
import { useLayout } from "@/lib/hooks/useLayout";
|
||||||
|
import type { WidgetPlacement } from "@mosaic/shared";
|
||||||
|
|
||||||
|
export interface HUDProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry of available widget components
|
||||||
|
* This will be populated with actual widget components
|
||||||
|
*/
|
||||||
|
const WIDGET_REGISTRY = {
|
||||||
|
TasksWidget: {
|
||||||
|
name: "tasks",
|
||||||
|
displayName: "Tasks",
|
||||||
|
description: "View and manage your tasks",
|
||||||
|
defaultWidth: 2,
|
||||||
|
defaultHeight: 3,
|
||||||
|
minWidth: 1,
|
||||||
|
minHeight: 2,
|
||||||
|
},
|
||||||
|
CalendarWidget: {
|
||||||
|
name: "calendar",
|
||||||
|
displayName: "Calendar",
|
||||||
|
description: "Upcoming events and schedule",
|
||||||
|
defaultWidth: 2,
|
||||||
|
defaultHeight: 2,
|
||||||
|
minWidth: 1,
|
||||||
|
minHeight: 2,
|
||||||
|
},
|
||||||
|
QuickCaptureWidget: {
|
||||||
|
name: "quick-capture",
|
||||||
|
displayName: "Quick Capture",
|
||||||
|
description: "Capture ideas and notes",
|
||||||
|
defaultWidth: 2,
|
||||||
|
defaultHeight: 1,
|
||||||
|
minWidth: 1,
|
||||||
|
minHeight: 1,
|
||||||
|
},
|
||||||
|
AgentStatusWidget: {
|
||||||
|
name: "agent-status",
|
||||||
|
displayName: "Agent Status",
|
||||||
|
description: "View running agent sessions",
|
||||||
|
defaultWidth: 2,
|
||||||
|
defaultHeight: 2,
|
||||||
|
minWidth: 1,
|
||||||
|
minHeight: 1,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type WidgetRegistryKey = keyof typeof WIDGET_REGISTRY;
|
||||||
|
|
||||||
|
export function HUD({ className = "" }: HUDProps) {
|
||||||
|
const {
|
||||||
|
currentLayout,
|
||||||
|
updateLayout,
|
||||||
|
addWidget,
|
||||||
|
removeWidget,
|
||||||
|
switchLayout,
|
||||||
|
resetLayout,
|
||||||
|
} = useLayout();
|
||||||
|
|
||||||
|
const isEditing = true; // For now, always in edit mode (can be toggled later)
|
||||||
|
|
||||||
|
const handleLayoutChange = (newLayout: readonly { i: string; x: number; y: number; w: number; h: number }[]) => {
|
||||||
|
updateLayout([...newLayout] as WidgetPlacement[]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddWidget = (widgetType: WidgetRegistryKey) => {
|
||||||
|
const widgetConfig = WIDGET_REGISTRY[widgetType];
|
||||||
|
const widgetId = `${widgetType.toLowerCase()}-${Date.now()}`;
|
||||||
|
|
||||||
|
// Find the next available position
|
||||||
|
const maxY = currentLayout?.layout.reduce((max, w) => Math.max(max, w.y + w.h), 0) || 0;
|
||||||
|
|
||||||
|
const newWidget = {
|
||||||
|
i: widgetId,
|
||||||
|
x: 0,
|
||||||
|
y: maxY,
|
||||||
|
w: widgetConfig.defaultWidth,
|
||||||
|
h: widgetConfig.defaultHeight,
|
||||||
|
minW: widgetConfig.minWidth,
|
||||||
|
minH: widgetConfig.minHeight,
|
||||||
|
isDraggable: true,
|
||||||
|
isResizable: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
addWidget(newWidget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetLayout = () => {
|
||||||
|
if (confirm("Are you sure you want to reset the layout? This will remove all widgets.")) {
|
||||||
|
resetLayout();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const widgetComponents = useMemo(() => {
|
||||||
|
if (!currentLayout?.layout) return [];
|
||||||
|
|
||||||
|
return currentLayout.layout.map((widget) => (
|
||||||
|
<WidgetRenderer
|
||||||
|
key={widget.i}
|
||||||
|
widget={widget}
|
||||||
|
isEditing={isEditing}
|
||||||
|
onRemove={removeWidget}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}, [currentLayout?.layout, isEditing, removeWidget]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`hud-container ${className}`}>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="bg-white border-b border-gray-200 px-6 py-4 mb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={currentLayout?.id || ""}
|
||||||
|
onChange={(e) => switchLayout(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Default Layout</option>
|
||||||
|
{/* Add more layout options here */}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleResetLayout}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Widget type selector */}
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
onChange={(e) => {
|
||||||
|
const widgetType = e.target.value as WidgetRegistryKey;
|
||||||
|
if (widgetType) {
|
||||||
|
handleAddWidget(widgetType);
|
||||||
|
e.target.value = "";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
|
||||||
|
defaultValue=""
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Add Widget
|
||||||
|
</option>
|
||||||
|
{Object.entries(WIDGET_REGISTRY).map(([key, config]) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{config.displayName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Widget Grid */}
|
||||||
|
<WidgetGrid
|
||||||
|
layout={currentLayout?.layout || []}
|
||||||
|
onLayoutChange={handleLayoutChange}
|
||||||
|
isEditing={isEditing}
|
||||||
|
>
|
||||||
|
{widgetComponents}
|
||||||
|
</WidgetGrid>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
apps/web/src/components/hud/WidgetGrid.tsx
Normal file
117
apps/web/src/components/hud/WidgetGrid.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Widget grid container using react-grid-layout
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Responsive as ResponsiveGridLayout, useContainerWidth } from "react-grid-layout";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { WidgetPlacement } from "@mosaic/shared";
|
||||||
|
|
||||||
|
// Import CSS for react-grid-layout
|
||||||
|
import "react-grid-layout/css/styles.css";
|
||||||
|
import "react-resizable/css/styles.css";
|
||||||
|
|
||||||
|
export interface WidgetGridProps {
|
||||||
|
children: ReactNode[];
|
||||||
|
layout: WidgetPlacement[];
|
||||||
|
onLayoutChange?: (layout: readonly WidgetPlacement[]) => void;
|
||||||
|
isEditing?: boolean;
|
||||||
|
breakpoints?: { [key: string]: number };
|
||||||
|
cols?: { [key: string]: number };
|
||||||
|
rowHeight?: number;
|
||||||
|
margin?: [number, number];
|
||||||
|
containerPadding?: [number, number];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_BREAKPOINTS = {
|
||||||
|
lg: 1200,
|
||||||
|
md: 996,
|
||||||
|
sm: 768,
|
||||||
|
xs: 480,
|
||||||
|
xxs: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_COLS = {
|
||||||
|
lg: 4,
|
||||||
|
md: 3,
|
||||||
|
sm: 2,
|
||||||
|
xs: 1,
|
||||||
|
xxs: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WidgetGrid({
|
||||||
|
children,
|
||||||
|
layout,
|
||||||
|
onLayoutChange,
|
||||||
|
isEditing = false,
|
||||||
|
breakpoints = DEFAULT_BREAKPOINTS,
|
||||||
|
cols = DEFAULT_COLS,
|
||||||
|
rowHeight = 100,
|
||||||
|
margin = [16, 16],
|
||||||
|
containerPadding = [16, 16],
|
||||||
|
className = "",
|
||||||
|
}: WidgetGridProps) {
|
||||||
|
// Use hook to measure container width
|
||||||
|
const { width, containerRef, mounted } = useContainerWidth({ measureBeforeMount: true });
|
||||||
|
// Convert our WidgetPlacement to react-grid-layout's Layout format
|
||||||
|
const rglLayout = layout.map((widget) => {
|
||||||
|
const layoutItem: {
|
||||||
|
i: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
minW: number;
|
||||||
|
maxW?: number;
|
||||||
|
minH: number;
|
||||||
|
maxH?: number;
|
||||||
|
static?: boolean;
|
||||||
|
isDraggable?: boolean;
|
||||||
|
isResizable?: boolean;
|
||||||
|
} = {
|
||||||
|
i: widget.i,
|
||||||
|
x: widget.x,
|
||||||
|
y: widget.y,
|
||||||
|
w: widget.w,
|
||||||
|
h: widget.h,
|
||||||
|
minW: widget.minW || 1,
|
||||||
|
minH: widget.minH || 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (widget.maxW !== undefined) layoutItem.maxW = widget.maxW;
|
||||||
|
if (widget.maxH !== undefined) layoutItem.maxH = widget.maxH;
|
||||||
|
if (widget.static) layoutItem.static = true;
|
||||||
|
if (isEditing && widget.isDraggable !== false) layoutItem.isDraggable = true;
|
||||||
|
if (isEditing && widget.isResizable !== false) layoutItem.isResizable = true;
|
||||||
|
|
||||||
|
return layoutItem;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleLayoutChange = (layout: readonly any[]) => {
|
||||||
|
if (onLayoutChange) {
|
||||||
|
onLayoutChange([...layout] as WidgetPlacement[]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} style={{ width: "100%" }}>
|
||||||
|
{mounted && (
|
||||||
|
<ResponsiveGridLayout
|
||||||
|
className={`widget-grid ${className}`}
|
||||||
|
layouts={{ lg: rglLayout }}
|
||||||
|
breakpoints={breakpoints}
|
||||||
|
cols={cols}
|
||||||
|
rowHeight={rowHeight}
|
||||||
|
margin={margin}
|
||||||
|
containerPadding={containerPadding}
|
||||||
|
onLayoutChange={handleLayoutChange}
|
||||||
|
width={width}
|
||||||
|
>
|
||||||
|
{children.map((child, index) => (
|
||||||
|
<div key={layout[index]?.i || index}>{child}</div>
|
||||||
|
))}
|
||||||
|
</ResponsiveGridLayout>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
apps/web/src/components/hud/WidgetRenderer.tsx
Normal file
74
apps/web/src/components/hud/WidgetRenderer.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Widget renderer - renders the appropriate widget component based on type
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { WidgetWrapper } from "./WidgetWrapper";
|
||||||
|
import { TasksWidget, CalendarWidget, QuickCaptureWidget, AgentStatusWidget } from "@/components/widgets";
|
||||||
|
import type { WidgetPlacement } from "@mosaic/shared";
|
||||||
|
|
||||||
|
export interface WidgetRendererProps {
|
||||||
|
widget: WidgetPlacement;
|
||||||
|
isEditing?: boolean;
|
||||||
|
onRemove?: (widgetId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WIDGET_COMPONENTS = {
|
||||||
|
tasks: TasksWidget,
|
||||||
|
calendar: CalendarWidget,
|
||||||
|
"quick-capture": QuickCaptureWidget,
|
||||||
|
"agent-status": AgentStatusWidget,
|
||||||
|
};
|
||||||
|
|
||||||
|
const WIDGET_CONFIG = {
|
||||||
|
tasks: {
|
||||||
|
displayName: "Tasks",
|
||||||
|
description: "View and manage your tasks",
|
||||||
|
},
|
||||||
|
calendar: {
|
||||||
|
displayName: "Calendar",
|
||||||
|
description: "Upcoming events and schedule",
|
||||||
|
},
|
||||||
|
"quick-capture": {
|
||||||
|
displayName: "Quick Capture",
|
||||||
|
description: "Capture ideas and notes",
|
||||||
|
},
|
||||||
|
"agent-status": {
|
||||||
|
displayName: "Agent Status",
|
||||||
|
description: "View running agent sessions",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WidgetRenderer({ widget, isEditing = false, onRemove }: WidgetRendererProps) {
|
||||||
|
// Extract widget type from ID (e.g., "tasks-123" -> "tasks")
|
||||||
|
const widgetType = widget.i.split("-")[0] as keyof typeof WIDGET_COMPONENTS;
|
||||||
|
const WidgetComponent = WIDGET_COMPONENTS[widgetType];
|
||||||
|
const config = WIDGET_CONFIG[widgetType] || { displayName: "Widget", description: "" };
|
||||||
|
|
||||||
|
if (!WidgetComponent) {
|
||||||
|
const wrapperProps = {
|
||||||
|
id: widget.i,
|
||||||
|
title: "Unknown Widget",
|
||||||
|
isEditing: isEditing,
|
||||||
|
...(onRemove && { onRemove: () => onRemove(widget.i) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WidgetWrapper {...wrapperProps}>
|
||||||
|
<div className="text-gray-500 text-sm">Widget type not found: {widgetType}</div>
|
||||||
|
</WidgetWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapperProps = {
|
||||||
|
id: widget.i,
|
||||||
|
title: config.displayName,
|
||||||
|
isEditing: isEditing,
|
||||||
|
...(onRemove && { onRemove: () => onRemove(widget.i) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WidgetWrapper {...wrapperProps}>
|
||||||
|
<WidgetComponent id={widget.i} />
|
||||||
|
</WidgetWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
apps/web/src/components/hud/WidgetWrapper.tsx
Normal file
109
apps/web/src/components/hud/WidgetWrapper.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Widget wrapper with drag/resize handles and edit controls
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ReactNode, useState } from "react";
|
||||||
|
import { Card, CardHeader, CardContent } from "@mosaic/ui";
|
||||||
|
import { GripVertical, Maximize2, Minimize2, X, Settings } from "lucide-react";
|
||||||
|
|
||||||
|
export interface WidgetWrapperProps {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
isEditing?: boolean;
|
||||||
|
isCollapsed?: boolean;
|
||||||
|
onEdit?: () => void;
|
||||||
|
onRemove?: () => void;
|
||||||
|
onToggleCollapse?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WidgetWrapper({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
isEditing = false,
|
||||||
|
isCollapsed = false,
|
||||||
|
onEdit,
|
||||||
|
onRemove,
|
||||||
|
onToggleCollapse,
|
||||||
|
className = "",
|
||||||
|
}: WidgetWrapperProps) {
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
id={id}
|
||||||
|
className={`relative flex flex-col h-full ${isCollapsed ? "min-h-[60px]" : ""} ${className}`}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
{/* Drag handle */}
|
||||||
|
{isEditing && (
|
||||||
|
<div className="absolute top-2 left-2 z-10 cursor-grab active:cursor-grabbing p-1 rounded hover:bg-gray-100 group">
|
||||||
|
<GripVertical className="w-4 h-4 text-gray-400 group-hover:text-gray-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<CardHeader
|
||||||
|
className={`px-4 py-3 border-b border-gray-200 flex items-center justify-between ${
|
||||||
|
isEditing ? "pr-20" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900">{title}</h3>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{!isEditing && (isHovered || isCollapsed) && (
|
||||||
|
<>
|
||||||
|
{onToggleCollapse && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleCollapse();
|
||||||
|
}}
|
||||||
|
className="p-1 rounded hover:bg-gray-100 text-gray-500 hover:text-gray-700"
|
||||||
|
title={isCollapsed ? "Expand" : "Collapse"}
|
||||||
|
>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<Maximize2 className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Minimize2 className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isCollapsed && onEdit && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
className="p-1 rounded hover:bg-gray-100 text-gray-500 hover:text-gray-700"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isEditing && onRemove && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove();
|
||||||
|
}}
|
||||||
|
className="p-1 rounded hover:bg-red-100 text-gray-500 hover:text-red-600"
|
||||||
|
title="Remove widget"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{!isCollapsed && <CardContent className="flex-1 overflow-auto p-4">{children}</CardContent>}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
apps/web/src/components/hud/index.ts
Normal file
12
apps/web/src/components/hud/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* HUD components
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { HUD } from "./HUD";
|
||||||
|
export { WidgetGrid } from "./WidgetGrid";
|
||||||
|
export { WidgetWrapper } from "./WidgetWrapper";
|
||||||
|
export { WidgetRenderer } from "./WidgetRenderer";
|
||||||
|
export type { WidgetWrapperProps } from "./WidgetWrapper";
|
||||||
|
export type { WidgetGridProps } from "./WidgetGrid";
|
||||||
|
export type { HUDProps } from "./HUD";
|
||||||
|
export type { WidgetRendererProps } from "./WidgetRenderer";
|
||||||
@@ -10,6 +10,7 @@ export function Navigation() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
|
{ href: "/", label: "Dashboard" },
|
||||||
{ href: "/tasks", label: "Tasks" },
|
{ href: "/tasks", label: "Tasks" },
|
||||||
{ href: "/calendar", label: "Calendar" },
|
{ href: "/calendar", label: "Calendar" },
|
||||||
];
|
];
|
||||||
@@ -19,7 +20,7 @@ export function Navigation() {
|
|||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="flex items-center justify-between h-16">
|
<div className="flex items-center justify-between h-16">
|
||||||
<div className="flex items-center gap-8">
|
<div className="flex items-center gap-8">
|
||||||
<Link href="/tasks" className="text-xl font-bold text-gray-900">
|
<Link href="/" className="text-xl font-bold text-gray-900">
|
||||||
Mosaic Stack
|
Mosaic Stack
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
|
|||||||
169
apps/web/src/components/widgets/AgentStatusWidget.tsx
Normal file
169
apps/web/src/components/widgets/AgentStatusWidget.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* Agent Status Widget - shows running agents
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Bot, Activity, AlertCircle, CheckCircle, Clock } from "lucide-react";
|
||||||
|
import type { WidgetProps } from "@mosaic/shared";
|
||||||
|
|
||||||
|
interface Agent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: "IDLE" | "WORKING" | "WAITING" | "ERROR" | "TERMINATED";
|
||||||
|
currentTask?: string;
|
||||||
|
lastHeartbeat: string;
|
||||||
|
taskCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps) {
|
||||||
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Mock data for now - will fetch from API later
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setAgents([
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
name: "Code Review Agent",
|
||||||
|
status: "WORKING",
|
||||||
|
currentTask: "Reviewing PR #123",
|
||||||
|
lastHeartbeat: new Date().toISOString(),
|
||||||
|
taskCount: 42,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
name: "Documentation Agent",
|
||||||
|
status: "IDLE",
|
||||||
|
lastHeartbeat: new Date().toISOString(),
|
||||||
|
taskCount: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
name: "Test Runner Agent",
|
||||||
|
status: "ERROR",
|
||||||
|
currentTask: "Failed to run tests",
|
||||||
|
lastHeartbeat: new Date(Date.now() - 300000).toISOString(),
|
||||||
|
taskCount: 28,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setIsLoading(false);
|
||||||
|
}, 500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getStatusIcon = (status: Agent["status"]) => {
|
||||||
|
switch (status) {
|
||||||
|
case "WORKING":
|
||||||
|
return <Activity className="w-4 h-4 text-blue-500 animate-pulse" />;
|
||||||
|
case "IDLE":
|
||||||
|
return <Clock className="w-4 h-4 text-gray-400" />;
|
||||||
|
case "WAITING":
|
||||||
|
return <Clock className="w-4 h-4 text-yellow-500" />;
|
||||||
|
case "ERROR":
|
||||||
|
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
||||||
|
case "TERMINATED":
|
||||||
|
return <CheckCircle className="w-4 h-4 text-gray-500" />;
|
||||||
|
default:
|
||||||
|
return <Clock className="w-4 h-4 text-gray-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status: Agent["status"]) => {
|
||||||
|
return status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTimeSinceLastHeartbeat = (timestamp: string) => {
|
||||||
|
const now = new Date();
|
||||||
|
const last = new Date(timestamp);
|
||||||
|
const diffMs = now.getTime() - last.getTime();
|
||||||
|
|
||||||
|
if (diffMs < 60000) return "Just now";
|
||||||
|
if (diffMs < 3600000) return `${Math.floor(diffMs / 60000)}m ago`;
|
||||||
|
if (diffMs < 86400000) return `${Math.floor(diffMs / 3600000)}h ago`;
|
||||||
|
return `${Math.floor(diffMs / 86400000)}d ago`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: agents.length,
|
||||||
|
working: agents.filter((a) => a.status === "WORKING").length,
|
||||||
|
idle: agents.filter((a) => a.status === "IDLE").length,
|
||||||
|
error: agents.filter((a) => a.status === "ERROR").length,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-gray-500 text-sm">Loading agents...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full space-y-3">
|
||||||
|
{/* Summary stats */}
|
||||||
|
<div className="grid grid-cols-4 gap-1 text-center text-xs">
|
||||||
|
<div className="bg-gray-50 rounded p-2">
|
||||||
|
<div className="text-lg font-bold text-gray-900">{stats.total}</div>
|
||||||
|
<div className="text-gray-500">Total</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 rounded p-2">
|
||||||
|
<div className="text-lg font-bold text-blue-600">{stats.working}</div>
|
||||||
|
<div className="text-blue-500">Working</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-100 rounded p-2">
|
||||||
|
<div className="text-lg font-bold text-gray-600">{stats.idle}</div>
|
||||||
|
<div className="text-gray-500">Idle</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-red-50 rounded p-2">
|
||||||
|
<div className="text-lg font-bold text-red-600">{stats.error}</div>
|
||||||
|
<div className="text-red-500">Error</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent list */}
|
||||||
|
<div className="flex-1 overflow-auto space-y-2">
|
||||||
|
{agents.length === 0 ? (
|
||||||
|
<div className="text-center text-gray-500 text-sm py-4">
|
||||||
|
No agents configured
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
agents.map((agent) => (
|
||||||
|
<div
|
||||||
|
key={agent.id}
|
||||||
|
className={`p-3 rounded-lg border ${
|
||||||
|
agent.status === "ERROR"
|
||||||
|
? "bg-red-50 border-red-200"
|
||||||
|
: agent.status === "WORKING"
|
||||||
|
? "bg-blue-50 border-blue-200"
|
||||||
|
: "bg-gray-50 border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bot className="w-4 h-4 text-gray-600" />
|
||||||
|
<span className="text-sm font-medium text-gray-900">
|
||||||
|
{agent.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
{getStatusIcon(agent.status)}
|
||||||
|
<span>{getStatusText(agent.status)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{agent.currentTask && (
|
||||||
|
<div className="text-xs text-gray-600 mb-1">{agent.currentTask}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-400">
|
||||||
|
<span>{agent.taskCount} tasks completed</span>
|
||||||
|
<span>{getTimeSinceLastHeartbeat(agent.lastHeartbeat)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
apps/web/src/components/widgets/CalendarWidget.tsx
Normal file
141
apps/web/src/components/widgets/CalendarWidget.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* Calendar Widget - displays upcoming events
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Calendar as CalendarIcon, Clock, MapPin } from "lucide-react";
|
||||||
|
import type { WidgetProps } from "@mosaic/shared";
|
||||||
|
|
||||||
|
interface Event {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime?: string;
|
||||||
|
location?: string;
|
||||||
|
allDay: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarWidget({ id: _id, config: _config }: WidgetProps) {
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Mock data for now - will fetch from API later
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setEvents([
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
title: "Team Standup",
|
||||||
|
startTime: new Date(today.setHours(9, 0, 0, 0)).toISOString(),
|
||||||
|
endTime: new Date(today.setHours(9, 30, 0, 0)).toISOString(),
|
||||||
|
location: "Zoom",
|
||||||
|
allDay: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
title: "Project Review",
|
||||||
|
startTime: new Date(today.setHours(14, 0, 0, 0)).toISOString(),
|
||||||
|
endTime: new Date(today.setHours(15, 0, 0, 0)).toISOString(),
|
||||||
|
location: "Conference Room A",
|
||||||
|
allDay: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
title: "Sprint Planning",
|
||||||
|
startTime: new Date(tomorrow.setHours(10, 0, 0, 0)).toISOString(),
|
||||||
|
endTime: new Date(tomorrow.setHours(12, 0, 0, 0)).toISOString(),
|
||||||
|
allDay: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setIsLoading(false);
|
||||||
|
}, 500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatTime = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleTimeString("en-US", {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDay = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const today = new Date();
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
|
if (date.toDateString() === today.toDateString()) {
|
||||||
|
return "Today";
|
||||||
|
} else if (date.toDateString() === tomorrow.toDateString()) {
|
||||||
|
return "Tomorrow";
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUpcomingEvents = () => {
|
||||||
|
const now = new Date();
|
||||||
|
return events
|
||||||
|
.filter((e) => new Date(e.startTime) > now)
|
||||||
|
.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime())
|
||||||
|
.slice(0, 5);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-gray-500 text-sm">Loading events...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const upcomingEvents = getUpcomingEvents();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full space-y-3">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2 text-gray-700">
|
||||||
|
<CalendarIcon className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">Upcoming Events</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event list */}
|
||||||
|
<div className="flex-1 overflow-auto space-y-3">
|
||||||
|
{upcomingEvents.length === 0 ? (
|
||||||
|
<div className="text-center text-gray-500 text-sm py-4">No upcoming events</div>
|
||||||
|
) : (
|
||||||
|
upcomingEvents.map((event) => (
|
||||||
|
<div key={event.id} className="border-l-2 border-blue-500 pl-3 py-1">
|
||||||
|
<div className="text-sm font-medium text-gray-900">{event.title}</div>
|
||||||
|
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500">
|
||||||
|
{!event.allDay && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
<span>
|
||||||
|
{formatTime(event.startTime)}
|
||||||
|
{event.endTime && ` - ${formatTime(event.endTime)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{event.location && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
<span>{event.location}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-gray-400">{formatDay(event.startTime)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
apps/web/src/components/widgets/QuickCaptureWidget.tsx
Normal file
94
apps/web/src/components/widgets/QuickCaptureWidget.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Quick Capture Widget - idea/brain dump input
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Send, Lightbulb } from "lucide-react";
|
||||||
|
import type { WidgetProps } from "@mosaic/shared";
|
||||||
|
|
||||||
|
export function QuickCaptureWidget({ id: _id, config: _config }: WidgetProps) {
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [recentCaptures, setRecentCaptures] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!input.trim() || isSubmitting) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const idea = input.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: Replace with actual API call
|
||||||
|
// await api.ideas.create({ content: idea });
|
||||||
|
|
||||||
|
// Add to recent captures for visual feedback
|
||||||
|
setRecentCaptures((prev) => [idea, ...prev].slice(0, 3));
|
||||||
|
setInput("");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to capture idea:", error);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full space-y-3">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2 text-gray-700">
|
||||||
|
<Lightbulb className="w-4 h-4 text-yellow-500" />
|
||||||
|
<span className="text-sm font-medium">Quick Capture</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input form */}
|
||||||
|
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder="Capture an idea..."
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!input.trim() || isSubmitting}
|
||||||
|
className="px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Recent captures */}
|
||||||
|
{recentCaptures.length > 0 && (
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<div className="text-xs text-gray-500 mb-2">Recently captured:</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{recentCaptures.map((capture, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="p-2 bg-gray-50 rounded text-sm text-gray-700"
|
||||||
|
>
|
||||||
|
{capture}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tips */}
|
||||||
|
{recentCaptures.length === 0 && (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-center text-gray-400 text-xs space-y-1">
|
||||||
|
<div>Capture ideas quickly</div>
|
||||||
|
<div>They'll be organized later</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
apps/web/src/components/widgets/TasksWidget.tsx
Normal file
134
apps/web/src/components/widgets/TasksWidget.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Tasks Widget - displays task summary and list
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { CheckCircle, Circle, Clock, AlertCircle } from "lucide-react";
|
||||||
|
import type { WidgetProps } from "@mosaic/shared";
|
||||||
|
|
||||||
|
interface Task {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
priority: string;
|
||||||
|
dueDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TasksWidget({ }: WidgetProps) {
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Mock data for now - will fetch from API later
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
// Simulate API call
|
||||||
|
setTimeout(() => {
|
||||||
|
setTasks([
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
title: "Complete project documentation",
|
||||||
|
status: "IN_PROGRESS",
|
||||||
|
priority: "HIGH",
|
||||||
|
dueDate: "2024-02-01",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
title: "Review pull requests",
|
||||||
|
status: "NOT_STARTED",
|
||||||
|
priority: "MEDIUM",
|
||||||
|
dueDate: "2024-02-02",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
title: "Update dependencies",
|
||||||
|
status: "COMPLETED",
|
||||||
|
priority: "LOW",
|
||||||
|
dueDate: "2024-01-30",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setIsLoading(false);
|
||||||
|
}, 500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getPriorityIcon = (priority: string) => {
|
||||||
|
switch (priority) {
|
||||||
|
case "HIGH":
|
||||||
|
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
||||||
|
case "MEDIUM":
|
||||||
|
return <Clock className="w-4 h-4 text-yellow-500" />;
|
||||||
|
case "LOW":
|
||||||
|
return <Circle className="w-4 h-4 text-gray-400" />;
|
||||||
|
default:
|
||||||
|
return <Circle className="w-4 h-4 text-gray-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
return status === "COMPLETED" ? (
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Circle className="w-4 h-4 text-gray-400" />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: tasks.length,
|
||||||
|
inProgress: tasks.filter((t) => t.status === "IN_PROGRESS").length,
|
||||||
|
completed: tasks.filter((t) => t.status === "COMPLETED").length,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-gray-500 text-sm">Loading tasks...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full space-y-3">
|
||||||
|
{/* Summary stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-center">
|
||||||
|
<div className="bg-gray-50 rounded p-2">
|
||||||
|
<div className="text-2xl font-bold text-gray-900">{stats.total}</div>
|
||||||
|
<div className="text-xs text-gray-500">Total</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 rounded p-2">
|
||||||
|
<div className="text-2xl font-bold text-blue-600">{stats.inProgress}</div>
|
||||||
|
<div className="text-xs text-blue-500">In Progress</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-green-50 rounded p-2">
|
||||||
|
<div className="text-2xl font-bold text-green-600">{stats.completed}</div>
|
||||||
|
<div className="text-xs text-green-500">Done</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task list */}
|
||||||
|
<div className="flex-1 overflow-auto space-y-2">
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<div className="text-center text-gray-500 text-sm py-4">No tasks yet</div>
|
||||||
|
) : (
|
||||||
|
tasks.slice(0, 5).map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className="flex items-center gap-3 p-2 hover:bg-gray-50 rounded transition-colors"
|
||||||
|
>
|
||||||
|
{getStatusIcon(task.status)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{task.title}
|
||||||
|
</div>
|
||||||
|
{task.dueDate && (
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
Due: {new Date(task.dueDate).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{getPriorityIcon(task.priority)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
apps/web/src/components/widgets/index.ts
Normal file
8
apps/web/src/components/widgets/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Widget components
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { TasksWidget } from "./TasksWidget";
|
||||||
|
export { CalendarWidget } from "./CalendarWidget";
|
||||||
|
export { QuickCaptureWidget } from "./QuickCaptureWidget";
|
||||||
|
export { AgentStatusWidget } from "./AgentStatusWidget";
|
||||||
22
apps/web/src/components/widgets/widget-registry.ts
Normal file
22
apps/web/src/components/widgets/widget-registry.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Widget component registry - React-specific definitions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ComponentType } from "react";
|
||||||
|
import type { WidgetProps } from "@mosaic/shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget component registry
|
||||||
|
*/
|
||||||
|
export interface WidgetComponent {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
description: string;
|
||||||
|
component: ComponentType<WidgetProps>;
|
||||||
|
defaultWidth: number;
|
||||||
|
defaultHeight: number;
|
||||||
|
minWidth: number;
|
||||||
|
minHeight: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
}
|
||||||
5
apps/web/src/lib/hooks/index.ts
Normal file
5
apps/web/src/lib/hooks/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Custom hooks
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { useLayout, useWorkspaceId } from "./useLayout";
|
||||||
227
apps/web/src/lib/hooks/useLayout.ts
Normal file
227
apps/web/src/lib/hooks/useLayout.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
/**
|
||||||
|
* Hook for managing widget layouts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useState, useEffect } from "react";
|
||||||
|
import type { WidgetPlacement, LayoutConfig } from "@mosaic/shared";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "mosaic-layout";
|
||||||
|
const DEFAULT_LAYOUT_NAME = "default";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local storage key for user's workspace preference
|
||||||
|
*/
|
||||||
|
const WORKSPACE_KEY = "mosaic-workspace-id";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage widget layout state
|
||||||
|
*/
|
||||||
|
export function useLayout() {
|
||||||
|
const [layouts, setLayouts] = useState<Record<string, LayoutConfig>>({});
|
||||||
|
const [currentLayoutId, setCurrentLayoutId] = useState<string>(DEFAULT_LAYOUT_NAME);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Load layouts from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
setLayouts(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load current layout ID preference
|
||||||
|
const storedLayoutId = localStorage.getItem(`${STORAGE_KEY}-current`);
|
||||||
|
if (storedLayoutId) {
|
||||||
|
setCurrentLayoutId(storedLayoutId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load layouts from localStorage:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save layouts to localStorage whenever they change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(layouts));
|
||||||
|
localStorage.setItem(`${STORAGE_KEY}-current`, currentLayoutId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save layouts to localStorage:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [layouts, currentLayoutId, isLoading]);
|
||||||
|
|
||||||
|
const currentLayout = layouts[currentLayoutId];
|
||||||
|
|
||||||
|
const updateLayout = useCallback(
|
||||||
|
(layoutItems: WidgetPlacement[]) => {
|
||||||
|
setLayouts((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[currentLayoutId]: {
|
||||||
|
id: currentLayoutId,
|
||||||
|
name: prev[currentLayoutId]?.name || "My Layout",
|
||||||
|
layout: layoutItems,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[currentLayoutId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const addWidget = useCallback(
|
||||||
|
(widget: WidgetPlacement) => {
|
||||||
|
setLayouts((prev) => {
|
||||||
|
const currentLayoutData = prev[currentLayoutId];
|
||||||
|
if (!currentLayoutData) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[currentLayoutId]: {
|
||||||
|
...currentLayoutData,
|
||||||
|
layout: [...currentLayoutData.layout, widget],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[currentLayoutId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeWidget = useCallback(
|
||||||
|
(widgetId: string) => {
|
||||||
|
setLayouts((prev) => {
|
||||||
|
const currentLayoutData = prev[currentLayoutId];
|
||||||
|
if (!currentLayoutData) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[currentLayoutId]: {
|
||||||
|
...currentLayoutData,
|
||||||
|
layout: currentLayoutData.layout.filter((w) => w.i !== widgetId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[currentLayoutId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateWidget = useCallback(
|
||||||
|
(widgetId: string, updates: Partial<WidgetPlacement>) => {
|
||||||
|
setLayouts((prev) => {
|
||||||
|
const currentLayoutData = prev[currentLayoutId];
|
||||||
|
if (!currentLayoutData) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[currentLayoutId]: {
|
||||||
|
...currentLayoutData,
|
||||||
|
layout: currentLayoutData.layout.map((w) =>
|
||||||
|
w.i === widgetId ? { ...w, ...updates } : w
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[currentLayoutId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const createLayout = useCallback(
|
||||||
|
(name: string) => {
|
||||||
|
const id = `layout-${Date.now()}`;
|
||||||
|
setLayouts((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[id]: {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
layout: [],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
setCurrentLayoutId(id);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteLayout = useCallback(
|
||||||
|
(layoutId: string) => {
|
||||||
|
setLayouts((prev) => {
|
||||||
|
const { [layoutId]: deleted, ...rest } = prev;
|
||||||
|
// If we deleted the current layout, switch to default
|
||||||
|
if (layoutId === currentLayoutId) {
|
||||||
|
const remainingIds = Object.keys(rest);
|
||||||
|
setCurrentLayoutId(remainingIds[0] || DEFAULT_LAYOUT_NAME);
|
||||||
|
}
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[currentLayoutId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renameLayout = useCallback((layoutId: string, name: string) => {
|
||||||
|
setLayouts((prev) => {
|
||||||
|
const existing = prev[layoutId];
|
||||||
|
if (!existing) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[layoutId]: {
|
||||||
|
...existing,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetLayout = useCallback(() => {
|
||||||
|
setLayouts({
|
||||||
|
[DEFAULT_LAYOUT_NAME]: {
|
||||||
|
id: DEFAULT_LAYOUT_NAME,
|
||||||
|
name: "Default Layout",
|
||||||
|
layout: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setCurrentLayoutId(DEFAULT_LAYOUT_NAME);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
layouts,
|
||||||
|
currentLayout,
|
||||||
|
currentLayoutId,
|
||||||
|
isLoading,
|
||||||
|
updateLayout,
|
||||||
|
addWidget,
|
||||||
|
removeWidget,
|
||||||
|
updateWidget,
|
||||||
|
createLayout,
|
||||||
|
deleteLayout,
|
||||||
|
renameLayout,
|
||||||
|
switchLayout: setCurrentLayoutId,
|
||||||
|
resetLayout,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get the current workspace ID
|
||||||
|
*/
|
||||||
|
export function useWorkspaceId(): string | null {
|
||||||
|
const [workspaceId, setWorkspaceId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(WORKSPACE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
setWorkspaceId(stored);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load workspace ID from localStorage:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return workspaceId;
|
||||||
|
}
|
||||||
@@ -128,3 +128,6 @@ export * from "./database.types";
|
|||||||
|
|
||||||
// Export authentication types
|
// Export authentication types
|
||||||
export * from "./auth.types";
|
export * from "./auth.types";
|
||||||
|
|
||||||
|
// Export widget types
|
||||||
|
export * from "./widget.types";
|
||||||
|
|||||||
81
packages/shared/src/types/widget.types.ts
Normal file
81
packages/shared/src/types/widget.types.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Widget and layout type definitions for HUD system
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BaseEntity } from "./index";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget placement in the grid
|
||||||
|
*/
|
||||||
|
export interface WidgetPlacement {
|
||||||
|
i: string; // Widget ID
|
||||||
|
x: number; // Column position
|
||||||
|
y: number; // Row position
|
||||||
|
w: number; // Width in grid units
|
||||||
|
h: number; // Height in grid units
|
||||||
|
minW?: number;
|
||||||
|
maxW?: number;
|
||||||
|
minH?: number;
|
||||||
|
maxH?: number;
|
||||||
|
static?: boolean;
|
||||||
|
isDraggable?: boolean;
|
||||||
|
isResizable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget definition from database
|
||||||
|
*/
|
||||||
|
export interface WidgetDefinition extends BaseEntity {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
description: string | null;
|
||||||
|
component: string;
|
||||||
|
defaultWidth: number;
|
||||||
|
defaultHeight: number;
|
||||||
|
minWidth: number;
|
||||||
|
minHeight: number;
|
||||||
|
maxWidth: number | null;
|
||||||
|
maxHeight: number | null;
|
||||||
|
configSchema: Record<string, unknown>;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User layout configuration
|
||||||
|
*/
|
||||||
|
export interface UserLayout extends BaseEntity {
|
||||||
|
workspaceId: string;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
layout: WidgetPlacement[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout configuration for editor
|
||||||
|
*/
|
||||||
|
export interface LayoutConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
layout: WidgetPlacement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available widget types (component names)
|
||||||
|
*/
|
||||||
|
export type WidgetComponentType =
|
||||||
|
| "TasksWidget"
|
||||||
|
| "CalendarWidget"
|
||||||
|
| "QuickCaptureWidget"
|
||||||
|
| "AgentStatusWidget";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for individual widgets
|
||||||
|
*/
|
||||||
|
export interface WidgetProps {
|
||||||
|
id: string;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
onEdit?: () => void;
|
||||||
|
onRemove?: () => void;
|
||||||
|
}
|
||||||
67
packages/ui/src/components/Avatar.tsx
Normal file
67
packages/ui/src/components/Avatar.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { ImgHTMLAttributes, ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface AvatarProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "size"> {
|
||||||
|
src?: string;
|
||||||
|
alt?: string;
|
||||||
|
size?: "sm" | "md" | "lg" | "xl";
|
||||||
|
fallback?: ReactNode;
|
||||||
|
initials?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({
|
||||||
|
src,
|
||||||
|
alt = "User avatar",
|
||||||
|
size = "md",
|
||||||
|
fallback,
|
||||||
|
initials,
|
||||||
|
className = "",
|
||||||
|
...props
|
||||||
|
}: AvatarProps) {
|
||||||
|
const sizeStyles = {
|
||||||
|
sm: "w-6 h-6 text-xs",
|
||||||
|
md: "w-8 h-8 text-sm",
|
||||||
|
lg: "w-12 h-12 text-base",
|
||||||
|
xl: "w-16 h-16 text-xl",
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseStyles = "rounded-full overflow-hidden flex items-center justify-center bg-gray-200 font-medium text-gray-600";
|
||||||
|
|
||||||
|
const combinedClassName = [baseStyles, sizeStyles[size], className].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
if (src) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className={`${combinedClassName} object-cover`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallback) {
|
||||||
|
return <div className={combinedClassName}>{fallback}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initials) {
|
||||||
|
return <div className={combinedClassName}>{initials}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default fallback with user icon
|
||||||
|
return (
|
||||||
|
<div className={combinedClassName}>
|
||||||
|
<svg
|
||||||
|
className="w-1/2 h-1/2 text-gray-400"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
packages/ui/src/components/Badge.tsx
Normal file
30
packages/ui/src/components/Badge.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { HTMLAttributes, ReactNode } from "react";
|
||||||
|
|
||||||
|
export type BadgeVariant = "priority-high" | "priority-medium" | "priority-low" | "status-success" | "status-warning" | "status-error" | "status-info" | "status-neutral";
|
||||||
|
|
||||||
|
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||||
|
variant?: BadgeVariant;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantStyles: Record<BadgeVariant, string> = {
|
||||||
|
"priority-high": "bg-red-100 text-red-800 border-red-200",
|
||||||
|
"priority-medium": "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||||
|
"priority-low": "bg-green-100 text-green-800 border-green-200",
|
||||||
|
"status-success": "bg-green-100 text-green-800 border-green-200",
|
||||||
|
"status-warning": "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||||
|
"status-error": "bg-red-100 text-red-800 border-red-200",
|
||||||
|
"status-info": "bg-blue-100 text-blue-800 border-blue-200",
|
||||||
|
"status-neutral": "bg-gray-100 text-gray-800 border-gray-200",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Badge({ variant = "status-neutral", children, className = "", ...props }: BadgeProps) {
|
||||||
|
const baseStyles = "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border";
|
||||||
|
const combinedClassName = [baseStyles, variantStyles[variant], className].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={combinedClassName} role="status" aria-label={children as string} {...props}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
|
|
||||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: "primary" | "secondary" | "danger";
|
variant?: "primary" | "secondary" | "danger" | "ghost";
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,7 @@ export function Button({
|
|||||||
primary: "bg-blue-600 text-white hover:bg-blue-700",
|
primary: "bg-blue-600 text-white hover:bg-blue-700",
|
||||||
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
|
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
|
||||||
danger: "bg-red-600 text-white hover:bg-red-700",
|
danger: "bg-red-600 text-white hover:bg-red-700",
|
||||||
|
ghost: "bg-transparent text-gray-700 hover:bg-gray-100 border border-gray-300",
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeStyles = {
|
const sizeStyles = {
|
||||||
|
|||||||
57
packages/ui/src/components/Card.tsx
Normal file
57
packages/ui/src/components/Card.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface CardProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
id?: string;
|
||||||
|
onMouseEnter?: () => void;
|
||||||
|
onMouseLeave?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardHeaderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardContentProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardFooterProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ children, className = "", id, onMouseEnter, onMouseLeave }: CardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={id}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
className={`bg-white rounded-lg shadow-md border border-gray-200 ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHeader({ children, className = "" }: CardHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className={`px-6 py-4 border-b border-gray-200 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardContent({ children, className = "" }: CardContentProps) {
|
||||||
|
return <div className={`px-6 py-4 ${className}`}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardFooter({ children, className = "" }: CardFooterProps) {
|
||||||
|
return (
|
||||||
|
<div className={`px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
packages/ui/src/components/Input.tsx
Normal file
58
packages/ui/src/components/Input.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { InputHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size"> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
helperText?: string;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input({
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
|
fullWidth = false,
|
||||||
|
className = "",
|
||||||
|
id,
|
||||||
|
...props
|
||||||
|
}: InputProps) {
|
||||||
|
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
const errorId = error ? `${inputId}-error` : undefined;
|
||||||
|
const helperId = helperText ? `${inputId}-helper` : undefined;
|
||||||
|
|
||||||
|
const baseStyles = "px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors";
|
||||||
|
const widthStyles = fullWidth ? "w-full" : "";
|
||||||
|
const errorStyles = error ? "border-red-500 focus:ring-red-500" : "border-gray-300";
|
||||||
|
|
||||||
|
const combinedClassName = [baseStyles, widthStyles, errorStyles, className].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={fullWidth ? "w-full" : ""}>
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={inputId}
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
className={combinedClassName}
|
||||||
|
aria-invalid={error ? "true" : "false"}
|
||||||
|
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{helperText && !error && (
|
||||||
|
<p id={helperId} className="mt-1 text-sm text-gray-500">
|
||||||
|
{helperText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
packages/ui/src/components/Modal.tsx
Normal file
121
packages/ui/src/components/Modal.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useEffect, useRef, type ReactNode, type HTMLAttributes } from "react";
|
||||||
|
|
||||||
|
export interface ModalProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
footer?: ReactNode;
|
||||||
|
closeOnOverlayClick?: boolean;
|
||||||
|
closeOnEscape?: boolean;
|
||||||
|
size?: "sm" | "md" | "lg" | "xl" | "full";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
footer,
|
||||||
|
closeOnOverlayClick = true,
|
||||||
|
closeOnEscape = true,
|
||||||
|
size = "md",
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
...props
|
||||||
|
}: ModalProps) {
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
const modalId = useRef(`modal-${Math.random().toString(36).substr(2, 9)}`);
|
||||||
|
|
||||||
|
const sizeStyles = {
|
||||||
|
sm: "max-w-md",
|
||||||
|
md: "max-w-lg",
|
||||||
|
lg: "max-w-2xl",
|
||||||
|
xl: "max-w-4xl",
|
||||||
|
full: "max-w-full mx-4",
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (closeOnEscape && event.key === "Escape" && isOpen) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
// Focus the modal when opened
|
||||||
|
dialogRef.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleEscape);
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [isOpen, closeOnEscape, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOverlayClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (closeOnOverlayClick && event.target === event.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4"
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={title ? `${modalId.current}-title` : undefined}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
className={`bg-white rounded-lg shadow-xl w-full ${sizeStyles[size]} ${className}`}
|
||||||
|
role="document"
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h2
|
||||||
|
id={`${modalId.current}-title`}
|
||||||
|
className="text-lg font-semibold text-gray-900"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
|
||||||
|
aria-label="Close modal"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="px-6 py-4 max-h-[70vh] overflow-y-auto">{children}</div>
|
||||||
|
{footer && (
|
||||||
|
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg flex justify-end gap-2">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
packages/ui/src/components/Select.tsx
Normal file
81
packages/ui/src/components/Select.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import type { SelectHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "size"> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
helperText?: string;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
options: SelectOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
|
fullWidth = false,
|
||||||
|
options,
|
||||||
|
placeholder = "Select an option...",
|
||||||
|
className = "",
|
||||||
|
id,
|
||||||
|
...props
|
||||||
|
}: SelectProps) {
|
||||||
|
const selectId = id || `select-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
const errorId = error ? `${selectId}-error` : undefined;
|
||||||
|
const helperId = helperText ? `${selectId}-helper` : undefined;
|
||||||
|
|
||||||
|
const baseStyles = "px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors bg-white";
|
||||||
|
const widthStyles = fullWidth ? "w-full" : "";
|
||||||
|
const errorStyles = error ? "border-red-500 focus:ring-red-500" : "border-gray-300";
|
||||||
|
|
||||||
|
const combinedClassName = [baseStyles, widthStyles, errorStyles, className].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={fullWidth ? "w-full" : ""}>
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={selectId}
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
id={selectId}
|
||||||
|
className={combinedClassName}
|
||||||
|
aria-invalid={error ? "true" : "false"}
|
||||||
|
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
{placeholder}
|
||||||
|
</option>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
disabled={option.disabled}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{error && (
|
||||||
|
<p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{helperText && !error && (
|
||||||
|
<p id={helperId} className="mt-1 text-sm text-gray-500">
|
||||||
|
{helperText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
packages/ui/src/components/Textarea.tsx
Normal file
72
packages/ui/src/components/Textarea.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { TextareaHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
export interface TextareaProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "size"> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
helperText?: string;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
resize?: "none" | "both" | "horizontal" | "vertical";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Textarea({
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
|
fullWidth = false,
|
||||||
|
resize = "vertical",
|
||||||
|
className = "",
|
||||||
|
id,
|
||||||
|
...props
|
||||||
|
}: TextareaProps) {
|
||||||
|
const textareaId = id || `textarea-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
const errorId = error ? `${textareaId}-error` : undefined;
|
||||||
|
const helperId = helperText ? `${textareaId}-helper` : undefined;
|
||||||
|
|
||||||
|
const baseStyles = "px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors";
|
||||||
|
const widthStyles = fullWidth ? "w-full" : "";
|
||||||
|
const resizeStyles = {
|
||||||
|
none: "resize-none",
|
||||||
|
both: "resize",
|
||||||
|
horizontal: "resize-x",
|
||||||
|
vertical: "resize-y",
|
||||||
|
};
|
||||||
|
const errorStyles = error ? "border-red-500 focus:ring-red-500" : "border-gray-300";
|
||||||
|
|
||||||
|
const combinedClassName = [
|
||||||
|
baseStyles,
|
||||||
|
widthStyles,
|
||||||
|
resizeStyles[resize],
|
||||||
|
errorStyles,
|
||||||
|
className,
|
||||||
|
].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={fullWidth ? "w-full" : ""}>
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={textareaId}
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
id={textareaId}
|
||||||
|
className={combinedClassName}
|
||||||
|
aria-invalid={error ? "true" : "false"}
|
||||||
|
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{helperText && !error && (
|
||||||
|
<p id={helperId} className="mt-1 text-sm text-gray-500">
|
||||||
|
{helperText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
packages/ui/src/components/Toast.tsx
Normal file
188
packages/ui/src/components/Toast.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
type ReactNode,
|
||||||
|
type HTMLAttributes,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
export type ToastVariant = "success" | "error" | "warning" | "info";
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
variant?: ToastVariant;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToastContextValue {
|
||||||
|
showToast: (message: string, variant?: ToastVariant, duration?: number) => void;
|
||||||
|
removeToast: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const context = useContext(ToastContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useToast must be used within a ToastProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToastProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: ToastProviderProps) {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: string) => {
|
||||||
|
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showToast = useCallback(
|
||||||
|
(message: string, variant: ToastVariant = "info", duration: number = 5000) => {
|
||||||
|
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
const newToast: Toast = { id, message, variant, duration };
|
||||||
|
|
||||||
|
setToasts((prev) => [...prev, newToast]);
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
removeToast(id);
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[removeToast]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ showToast, removeToast }}>
|
||||||
|
{children}
|
||||||
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToastContainerProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
toasts: Toast[];
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToastContainer({ toasts, onRemove, className = "" }: ToastContainerProps) {
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`fixed bottom-4 right-4 z-50 flex flex-col gap-2 ${className}`}
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<ToastItem key={toast.id} toast={toast} onRemove={onRemove} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastItemProps {
|
||||||
|
toast: Toast;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToastItem({ toast, onRemove }: ToastItemProps) {
|
||||||
|
const variantStyles: Record<ToastVariant, string> = {
|
||||||
|
success: "bg-green-500 text-white border-green-600",
|
||||||
|
error: "bg-red-500 text-white border-red-600",
|
||||||
|
warning: "bg-yellow-500 text-white border-yellow-600",
|
||||||
|
info: "bg-blue-500 text-white border-blue-600",
|
||||||
|
};
|
||||||
|
|
||||||
|
const icon: Record<ToastVariant, ReactNode> = {
|
||||||
|
success: (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
error: (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
warning: (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
info: (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${variantStyles[toast.variant || "info"]} border rounded-md shadow-lg px-4 py-3 flex items-center gap-3 min-w-[300px] max-w-md`}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<span className="flex-shrink-0">{icon[toast.variant || "info"]}</span>
|
||||||
|
<span className="flex-1 text-sm font-medium">{toast.message}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(toast.id)}
|
||||||
|
className="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity p-0.5 rounded hover:bg-white/20"
|
||||||
|
aria-label="Close notification"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to show toasts outside of React components
|
||||||
|
let toastContextValue: ToastContextValue | null = null;
|
||||||
|
|
||||||
|
export function setToastContext(context: ToastContextValue | null) {
|
||||||
|
toastContextValue = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToastOptions {
|
||||||
|
variant?: ToastVariant;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toast(message: string, options?: ToastOptions) {
|
||||||
|
if (!toastContextValue) {
|
||||||
|
console.warn("Toast context not available. Make sure ToastProvider is mounted.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toastContextValue.showToast(
|
||||||
|
message,
|
||||||
|
options?.variant || "info",
|
||||||
|
options?.duration || 5000
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,2 +1,35 @@
|
|||||||
|
// Button
|
||||||
export { Button } from "./components/Button.js";
|
export { Button } from "./components/Button.js";
|
||||||
export type { ButtonProps } from "./components/Button.js";
|
export type { ButtonProps } from "./components/Button.js";
|
||||||
|
|
||||||
|
// Card
|
||||||
|
export { Card, CardHeader, CardContent, CardFooter } from "./components/Card.js";
|
||||||
|
export type { CardProps, CardHeaderProps, CardContentProps, CardFooterProps } from "./components/Card.js";
|
||||||
|
|
||||||
|
// Badge
|
||||||
|
export { Badge } from "./components/Badge.js";
|
||||||
|
export type { BadgeProps, BadgeVariant } from "./components/Badge.js";
|
||||||
|
|
||||||
|
// Input
|
||||||
|
export { Input } from "./components/Input.js";
|
||||||
|
export type { InputProps } from "./components/Input.js";
|
||||||
|
|
||||||
|
// Textarea
|
||||||
|
export { Textarea } from "./components/Textarea.js";
|
||||||
|
export type { TextareaProps } from "./components/Textarea.js";
|
||||||
|
|
||||||
|
// Avatar
|
||||||
|
export { Avatar } from "./components/Avatar.js";
|
||||||
|
export type { AvatarProps } from "./components/Avatar.js";
|
||||||
|
|
||||||
|
// Select
|
||||||
|
export { Select } from "./components/Select.js";
|
||||||
|
export type { SelectProps, SelectOption } from "./components/Select.js";
|
||||||
|
|
||||||
|
// Modal
|
||||||
|
export { Modal } from "./components/Modal.js";
|
||||||
|
export type { ModalProps } from "./components/Modal.js";
|
||||||
|
|
||||||
|
// Toast
|
||||||
|
export { ToastProvider, useToast, toast } from "./components/Toast.js";
|
||||||
|
export type { Toast, ToastVariant, ToastContextValue, ToastProviderProps } from "./components/Toast.js";
|
||||||
|
|||||||
110
pnpm-lock.yaml
generated
110
pnpm-lock.yaml
generated
@@ -132,6 +132,9 @@ importers:
|
|||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
|
lucide-react:
|
||||||
|
specifier: ^0.563.0
|
||||||
|
version: 0.563.0(react@19.2.4)
|
||||||
next:
|
next:
|
||||||
specifier: ^16.1.6
|
specifier: ^16.1.6
|
||||||
version: 16.1.6(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 16.1.6(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@@ -141,6 +144,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.4(react@19.2.4)
|
version: 19.2.4(react@19.2.4)
|
||||||
|
react-grid-layout:
|
||||||
|
specifier: ^2.2.2
|
||||||
|
version: 2.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@mosaic/config':
|
'@mosaic/config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
@@ -163,6 +169,9 @@ importers:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^19.0.3
|
specifier: ^19.0.3
|
||||||
version: 19.2.3(@types/react@19.2.10)
|
version: 19.2.3(@types/react@19.2.10)
|
||||||
|
'@types/react-grid-layout':
|
||||||
|
specifier: ^2.1.0
|
||||||
|
version: 2.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^4.3.4
|
specifier: ^4.3.4
|
||||||
version: 4.7.0(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0))
|
version: 4.7.0(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0))
|
||||||
@@ -1592,6 +1601,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/react': ^19.2.0
|
'@types/react': ^19.2.0
|
||||||
|
|
||||||
|
'@types/react-grid-layout@2.1.0':
|
||||||
|
resolution: {integrity: sha512-pHEjVg9ert6BDFHFQ1IEdLUkd2gasJvyti5lV2kE46N/R07ZiaSZpAXeXJAA1MXy/Qby23fZmiuEgZkITxPXug==}
|
||||||
|
deprecated: This is a stub types definition. react-grid-layout provides its own type definitions, so you do not need this installed.
|
||||||
|
|
||||||
'@types/react@19.2.10':
|
'@types/react@19.2.10':
|
||||||
resolution: {integrity: sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==}
|
resolution: {integrity: sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==}
|
||||||
|
|
||||||
@@ -2137,6 +2150,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
|
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
|
clsx@2.1.1:
|
||||||
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@@ -2592,6 +2609,9 @@ packages:
|
|||||||
fast-diff@1.3.0:
|
fast-diff@1.3.0:
|
||||||
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
|
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
|
||||||
|
|
||||||
|
fast-equals@4.0.3:
|
||||||
|
resolution: {integrity: sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==}
|
||||||
|
|
||||||
fast-json-stable-stringify@2.1.0:
|
fast-json-stable-stringify@2.1.0:
|
||||||
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
|
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
|
||||||
|
|
||||||
@@ -2980,6 +3000,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
|
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
loose-envify@1.4.0:
|
||||||
|
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
loupe@3.2.1:
|
loupe@3.2.1:
|
||||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||||
|
|
||||||
@@ -2993,6 +3017,11 @@ packages:
|
|||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||||
|
|
||||||
|
lucide-react@0.563.0:
|
||||||
|
resolution: {integrity: sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
lz-string@1.5.0:
|
lz-string@1.5.0:
|
||||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -3378,6 +3407,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
prop-types@15.8.1:
|
||||||
|
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||||
|
|
||||||
proxy-addr@2.0.7:
|
proxy-addr@2.0.7:
|
||||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
@@ -3419,6 +3451,21 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.4
|
react: ^19.2.4
|
||||||
|
|
||||||
|
react-draggable@4.5.0:
|
||||||
|
resolution: {integrity: sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>= 16.3.0'
|
||||||
|
react-dom: '>= 16.3.0'
|
||||||
|
|
||||||
|
react-grid-layout@2.2.2:
|
||||||
|
resolution: {integrity: sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>= 16.3.0'
|
||||||
|
react-dom: '>= 16.3.0'
|
||||||
|
|
||||||
|
react-is@16.13.1:
|
||||||
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
react-is@17.0.2:
|
react-is@17.0.2:
|
||||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||||
|
|
||||||
@@ -3426,6 +3473,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
react-resizable@3.1.3:
|
||||||
|
resolution: {integrity: sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>= 16.3'
|
||||||
|
react-dom: '>= 16.3'
|
||||||
|
|
||||||
react@19.2.4:
|
react@19.2.4:
|
||||||
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
|
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3456,6 +3509,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
resize-observer-polyfill@1.5.1:
|
||||||
|
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
|
||||||
|
|
||||||
resolve-from@4.0.0:
|
resolve-from@4.0.0:
|
||||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -5480,6 +5536,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.2.10
|
'@types/react': 19.2.10
|
||||||
|
|
||||||
|
'@types/react-grid-layout@2.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
react-grid-layout: 2.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- react
|
||||||
|
- react-dom
|
||||||
|
|
||||||
'@types/react@19.2.10':
|
'@types/react@19.2.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
@@ -6142,6 +6205,8 @@ snapshots:
|
|||||||
|
|
||||||
clone@1.0.4: {}
|
clone@1.0.4: {}
|
||||||
|
|
||||||
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
@@ -6516,6 +6581,8 @@ snapshots:
|
|||||||
|
|
||||||
fast-diff@1.3.0: {}
|
fast-diff@1.3.0: {}
|
||||||
|
|
||||||
|
fast-equals@4.0.3: {}
|
||||||
|
|
||||||
fast-json-stable-stringify@2.1.0: {}
|
fast-json-stable-stringify@2.1.0: {}
|
||||||
|
|
||||||
fast-levenshtein@2.0.6: {}
|
fast-levenshtein@2.0.6: {}
|
||||||
@@ -6900,6 +6967,10 @@ snapshots:
|
|||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
is-unicode-supported: 0.1.0
|
is-unicode-supported: 0.1.0
|
||||||
|
|
||||||
|
loose-envify@1.4.0:
|
||||||
|
dependencies:
|
||||||
|
js-tokens: 4.0.0
|
||||||
|
|
||||||
loupe@3.2.1: {}
|
loupe@3.2.1: {}
|
||||||
|
|
||||||
lru-cache@10.4.3: {}
|
lru-cache@10.4.3: {}
|
||||||
@@ -6910,6 +6981,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yallist: 3.1.1
|
yallist: 3.1.1
|
||||||
|
|
||||||
|
lucide-react@0.563.0(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
|
||||||
lz-string@1.5.0: {}
|
lz-string@1.5.0: {}
|
||||||
|
|
||||||
magic-string@0.30.17:
|
magic-string@0.30.17:
|
||||||
@@ -7277,6 +7352,12 @@ snapshots:
|
|||||||
kleur: 3.0.3
|
kleur: 3.0.3
|
||||||
sisteransi: 1.0.5
|
sisteransi: 1.0.5
|
||||||
|
|
||||||
|
prop-types@15.8.1:
|
||||||
|
dependencies:
|
||||||
|
loose-envify: 1.4.0
|
||||||
|
object-assign: 4.1.1
|
||||||
|
react-is: 16.13.1
|
||||||
|
|
||||||
proxy-addr@2.0.7:
|
proxy-addr@2.0.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
forwarded: 0.2.0
|
forwarded: 0.2.0
|
||||||
@@ -7325,10 +7406,37 @@ snapshots:
|
|||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
scheduler: 0.27.0
|
scheduler: 0.27.0
|
||||||
|
|
||||||
|
react-draggable@4.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
clsx: 2.1.1
|
||||||
|
prop-types: 15.8.1
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
||||||
|
react-grid-layout@2.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
clsx: 2.1.1
|
||||||
|
fast-equals: 4.0.3
|
||||||
|
prop-types: 15.8.1
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
react-draggable: 4.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
react-resizable: 3.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
resize-observer-polyfill: 1.5.1
|
||||||
|
|
||||||
|
react-is@16.13.1: {}
|
||||||
|
|
||||||
react-is@17.0.2: {}
|
react-is@17.0.2: {}
|
||||||
|
|
||||||
react-refresh@0.17.0: {}
|
react-refresh@0.17.0: {}
|
||||||
|
|
||||||
|
react-resizable@3.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
prop-types: 15.8.1
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
react-draggable: 4.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
|
||||||
react@19.2.4: {}
|
react@19.2.4: {}
|
||||||
|
|
||||||
readable-stream@3.6.2:
|
readable-stream@3.6.2:
|
||||||
@@ -7352,6 +7460,8 @@ snapshots:
|
|||||||
|
|
||||||
require-from-string@2.0.2: {}
|
require-from-string@2.0.2: {}
|
||||||
|
|
||||||
|
resize-observer-polyfill@1.5.1: {}
|
||||||
|
|
||||||
resolve-from@4.0.0: {}
|
resolve-from@4.0.0: {}
|
||||||
|
|
||||||
resolve-pkg-maps@1.0.0: {}
|
resolve-pkg-maps@1.0.0: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user