Compare commits
13 Commits
96441b818e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cbfeb6db70 | |||
| fd823e2d75 | |||
| 602e1ed75b | |||
| fe18fc12f9 | |||
| dd2c80224e | |||
| d5693a7624 | |||
| b5066a0d33 | |||
| 504215439e | |||
| b7ff8437e4 | |||
| 46002e0854 | |||
| d01a155c95 | |||
| b8e490f635 | |||
| 268ee5d0b2 |
43
.gitea/workflows/ci.yml
Normal file
43
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
name: CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
run: bun test --bail
|
||||
|
||||
- name: Type check
|
||||
run: bun x tsc --noEmit
|
||||
|
||||
deploy:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- name: Deploy to Dokploy
|
||||
run: |
|
||||
curl -fsSL -X POST "${{ secrets.DOKPLOY_URL }}/api/compose.deploy" -H "Content-Type: application/json" -H "x-api-key: ${{ secrets.DOKPLOY_API_TOKEN }}" -d '{"composeId": "${{ secrets.DOKPLOY_COMPOSE_ID }}"}'
|
||||
echo "Deploy triggered on Dokploy"
|
||||
@@ -10,4 +10,6 @@ COPY . .
|
||||
|
||||
# Generate migrations and run
|
||||
EXPOSE 3100
|
||||
CMD ["sh", "-c", "bun run db:push && bun run start"]
|
||||
RUN apt-get update && apt-get install -y postgresql-client && rm -rf /var/lib/apt/lists/*
|
||||
COPY init-todos.sql /app/init-todos.sql
|
||||
CMD ["sh", "-c", "echo 'Running init SQL...' && psql \"$DATABASE_URL\" -f /app/init-todos.sql 2>&1 && echo 'Init SQL done' && echo 'Running db:push...' && yes | bun run db:push 2>&1; echo 'db:push exit code:' $? && echo 'Starting server...' && bun run start"]
|
||||
|
||||
21
backend/init-todos.sql
Normal file
21
backend/init-todos.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- Create todo_priority enum if not exists
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE todo_priority AS ENUM ('high', 'medium', 'low', 'none');
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- Create todos table if not exists
|
||||
CREATE TABLE IF NOT EXISTS todos (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
is_completed BOOLEAN NOT NULL DEFAULT false,
|
||||
priority todo_priority NOT NULL DEFAULT 'none',
|
||||
category TEXT,
|
||||
due_date TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -7,7 +7,10 @@
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"test": "bun test",
|
||||
"test:ci": "bun test --bail",
|
||||
"seed:security": "bun run src/seed-security.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/cors": "^1.2.0",
|
||||
|
||||
@@ -103,6 +103,105 @@ export const tasks = pgTable("tasks", {
|
||||
export type Task = typeof tasks.$inferSelect;
|
||||
export type NewTask = typeof tasks.$inferInsert;
|
||||
|
||||
// ─── Comments ───
|
||||
|
||||
export const taskComments = pgTable("task_comments", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
taskId: uuid("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }),
|
||||
authorId: text("author_id"), // BetterAuth user ID, or "hammer" for API, null for anonymous
|
||||
authorName: text("author_name").notNull(),
|
||||
content: text("content").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type TaskComment = typeof taskComments.$inferSelect;
|
||||
export type NewTaskComment = typeof taskComments.$inferInsert;
|
||||
|
||||
// ─── Security Audits ───
|
||||
|
||||
export const securityAuditStatusEnum = pgEnum("security_audit_status", [
|
||||
"strong",
|
||||
"needs_improvement",
|
||||
"critical",
|
||||
]);
|
||||
|
||||
export interface SecurityFinding {
|
||||
id: string;
|
||||
status: "strong" | "needs_improvement" | "critical";
|
||||
title: string;
|
||||
description: string;
|
||||
recommendation: string;
|
||||
}
|
||||
|
||||
export const securityAudits = pgTable("security_audits", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
projectName: text("project_name").notNull(),
|
||||
category: text("category").notNull(),
|
||||
findings: jsonb("findings").$type<SecurityFinding[]>().default([]),
|
||||
score: integer("score").notNull().default(0), // 0-100
|
||||
lastAudited: timestamp("last_audited", { withTimezone: true }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type SecurityAudit = typeof securityAudits.$inferSelect;
|
||||
export type NewSecurityAudit = typeof securityAudits.$inferInsert;
|
||||
|
||||
// ─── Daily Summaries ───
|
||||
|
||||
export interface SummaryHighlight {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SummaryStats {
|
||||
deploys?: number;
|
||||
commits?: number;
|
||||
tasksCompleted?: number;
|
||||
featuresBuilt?: number;
|
||||
bugsFixed?: number;
|
||||
[key: string]: number | undefined;
|
||||
}
|
||||
|
||||
export const dailySummaries = pgTable("daily_summaries", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
date: text("date").notNull().unique(), // YYYY-MM-DD
|
||||
content: text("content").notNull(),
|
||||
highlights: jsonb("highlights").$type<SummaryHighlight[]>().default([]),
|
||||
stats: jsonb("stats").$type<SummaryStats>().default({}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type DailySummary = typeof dailySummaries.$inferSelect;
|
||||
export type NewDailySummary = typeof dailySummaries.$inferInsert;
|
||||
|
||||
// ─── Personal Todos ───
|
||||
|
||||
export const todoPriorityEnum = pgEnum("todo_priority", [
|
||||
"high",
|
||||
"medium",
|
||||
"low",
|
||||
"none",
|
||||
]);
|
||||
|
||||
export const todos = pgTable("todos", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
userId: text("user_id").notNull(),
|
||||
title: text("title").notNull(),
|
||||
description: text("description"),
|
||||
isCompleted: boolean("is_completed").notNull().default(false),
|
||||
priority: todoPriorityEnum("priority").notNull().default("none"),
|
||||
category: text("category"),
|
||||
dueDate: timestamp("due_date", { withTimezone: true }),
|
||||
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type Todo = typeof todos.$inferSelect;
|
||||
export type NewTodo = typeof todos.$inferInsert;
|
||||
|
||||
// ─── BetterAuth tables ───
|
||||
|
||||
export const users = pgTable("users", {
|
||||
|
||||
@@ -3,7 +3,11 @@ import { cors } from "@elysiajs/cors";
|
||||
import { taskRoutes } from "./routes/tasks";
|
||||
import { adminRoutes } from "./routes/admin";
|
||||
import { projectRoutes } from "./routes/projects";
|
||||
import { chatRoutes } from "./routes/chat";
|
||||
import { commentRoutes } from "./routes/comments";
|
||||
import { activityRoutes } from "./routes/activity";
|
||||
import { summaryRoutes } from "./routes/summaries";
|
||||
import { securityRoutes } from "./routes/security";
|
||||
import { todoRoutes } from "./routes/todos";
|
||||
import { auth } from "./lib/auth";
|
||||
import { db } from "./db";
|
||||
import { tasks, users } from "./db/schema";
|
||||
@@ -115,9 +119,13 @@ const app = new Elysia()
|
||||
})
|
||||
|
||||
.use(taskRoutes)
|
||||
.use(commentRoutes)
|
||||
.use(activityRoutes)
|
||||
.use(projectRoutes)
|
||||
.use(adminRoutes)
|
||||
.use(chatRoutes)
|
||||
.use(securityRoutes)
|
||||
.use(summaryRoutes)
|
||||
.use(todoRoutes)
|
||||
|
||||
// Current user info (role, etc.)
|
||||
.get("/api/me", async ({ request }) => {
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
/**
|
||||
* Gateway WebSocket Relay
|
||||
*
|
||||
* Maintains a single persistent WebSocket connection to the Clawdbot gateway.
|
||||
* Dashboard clients connect through the backend (authenticated via BetterAuth),
|
||||
* and messages are relayed bidirectionally.
|
||||
*
|
||||
* Architecture:
|
||||
* Browser ←WSS→ Dashboard Backend ←WSS→ Clawdbot Gateway
|
||||
* (BetterAuth) (relay) (token auth)
|
||||
*/
|
||||
|
||||
const GATEWAY_URL = process.env.GATEWAY_WS_URL || "wss://ws.hammer.donovankelly.xyz";
|
||||
const GATEWAY_TOKEN = process.env.GATEWAY_WS_TOKEN || "";
|
||||
|
||||
type GatewayState = "disconnected" | "connecting" | "connected";
|
||||
type MessageHandler = (msg: any) => void;
|
||||
|
||||
let reqCounter = 0;
|
||||
function nextReqId() {
|
||||
return `relay-${++reqCounter}`;
|
||||
}
|
||||
|
||||
class GatewayConnection {
|
||||
private ws: WebSocket | null = null;
|
||||
private state: GatewayState = "disconnected";
|
||||
private pendingRequests = new Map<string, { resolve: (v: any) => void; reject: (e: any) => void; timer: ReturnType<typeof setTimeout> }>();
|
||||
private eventListeners = new Set<MessageHandler>();
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private shouldReconnect = true;
|
||||
private connectSent = false;
|
||||
private tickTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor() {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
private connect() {
|
||||
if (this.state === "connecting") return;
|
||||
this.state = "connecting";
|
||||
this.connectSent = false;
|
||||
|
||||
if (!GATEWAY_TOKEN) {
|
||||
console.warn("[gateway-relay] No GATEWAY_WS_TOKEN set, chat relay disabled");
|
||||
this.state = "disconnected";
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[gateway-relay] Connecting to ${GATEWAY_URL}...`);
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(GATEWAY_URL);
|
||||
} catch (e) {
|
||||
console.error("[gateway-relay] Failed to create WebSocket:", e);
|
||||
this.state = "disconnected";
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws.addEventListener("open", () => {
|
||||
console.log("[gateway-relay] WebSocket open, sending handshake...");
|
||||
this.sendConnect();
|
||||
});
|
||||
|
||||
this.ws.addEventListener("message", (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(String(event.data));
|
||||
|
||||
// Handle connect.challenge — gateway may send this before we connect
|
||||
if (msg.type === "event" && msg.event === "connect.challenge") {
|
||||
console.log("[gateway-relay] Received connect challenge");
|
||||
// Token auth doesn't need signing; send connect if not yet sent
|
||||
if (!this.connectSent) {
|
||||
this.sendConnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleMessage(msg);
|
||||
} catch (e) {
|
||||
console.error("[gateway-relay] Failed to parse message:", e);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.addEventListener("close", () => {
|
||||
console.log("[gateway-relay] Disconnected");
|
||||
this.state = "disconnected";
|
||||
this.ws = null;
|
||||
this.connectSent = false;
|
||||
if (this.tickTimer) {
|
||||
clearInterval(this.tickTimer);
|
||||
this.tickTimer = null;
|
||||
}
|
||||
// Reject all pending requests
|
||||
for (const [id, pending] of this.pendingRequests) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error("Connection closed"));
|
||||
this.pendingRequests.delete(id);
|
||||
}
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.addEventListener("error", () => {
|
||||
console.error("[gateway-relay] WebSocket error");
|
||||
});
|
||||
}
|
||||
|
||||
private sendConnect() {
|
||||
if (this.connectSent) return;
|
||||
this.connectSent = true;
|
||||
|
||||
const connectId = nextReqId();
|
||||
this.sendRaw({
|
||||
type: "req",
|
||||
id: connectId,
|
||||
method: "connect",
|
||||
params: {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: "dashboard-relay",
|
||||
displayName: "Hammer Dashboard",
|
||||
version: "1.0.0",
|
||||
platform: "server",
|
||||
mode: "webchat",
|
||||
instanceId: `relay-${process.pid}-${Date.now()}`,
|
||||
},
|
||||
role: "operator",
|
||||
scopes: ["operator.read", "operator.write"],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: {},
|
||||
auth: {
|
||||
token: GATEWAY_TOKEN,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for handshake response
|
||||
this.pendingRequests.set(connectId, {
|
||||
resolve: (payload) => {
|
||||
console.log("[gateway-relay] Connected to gateway, protocol:", payload?.protocol);
|
||||
this.state = "connected";
|
||||
|
||||
// Start tick keepalive (gateway expects periodic ticks)
|
||||
const tickInterval = payload?.policy?.tickIntervalMs || 15000;
|
||||
this.tickTimer = setInterval(() => {
|
||||
this.sendRaw({ type: "tick" });
|
||||
}, tickInterval);
|
||||
},
|
||||
reject: (err) => {
|
||||
console.error("[gateway-relay] Handshake failed:", err);
|
||||
this.state = "disconnected";
|
||||
this.ws?.close();
|
||||
},
|
||||
timer: setTimeout(() => {
|
||||
if (this.pendingRequests.has(connectId)) {
|
||||
this.pendingRequests.delete(connectId);
|
||||
console.error("[gateway-relay] Handshake timeout");
|
||||
this.state = "disconnected";
|
||||
this.ws?.close();
|
||||
}
|
||||
}, 15000),
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.reconnectTimer) return;
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
if (this.shouldReconnect && this.state === "disconnected") {
|
||||
this.connect();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
private sendRaw(msg: any) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(msg: any) {
|
||||
if (msg.type === "res") {
|
||||
const pending = this.pendingRequests.get(msg.id);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingRequests.delete(msg.id);
|
||||
if (msg.ok !== false) {
|
||||
pending.resolve(msg.payload ?? msg.result ?? {});
|
||||
} else {
|
||||
pending.reject(new Error(msg.error?.message || msg.error || "Request failed"));
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "event") {
|
||||
// Forward events to all listeners
|
||||
for (const listener of this.eventListeners) {
|
||||
try {
|
||||
listener(msg);
|
||||
} catch (e) {
|
||||
console.error("[gateway-relay] Event listener error:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ignore tick responses and other frame types
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.state === "connected";
|
||||
}
|
||||
|
||||
async request(method: string, params?: any): Promise<any> {
|
||||
if (!this.isConnected()) {
|
||||
throw new Error("Gateway not connected");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = nextReqId();
|
||||
const timer = setTimeout(() => {
|
||||
if (this.pendingRequests.has(id)) {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error("Request timeout"));
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
this.pendingRequests.set(id, { resolve, reject, timer });
|
||||
this.sendRaw({ type: "req", id, method, params });
|
||||
});
|
||||
}
|
||||
|
||||
onEvent(handler: MessageHandler): () => void {
|
||||
this.eventListeners.add(handler);
|
||||
return () => this.eventListeners.delete(handler);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.shouldReconnect = false;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.tickTimer) clearInterval(this.tickTimer);
|
||||
if (this.ws) {
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
this.ws = null;
|
||||
this.state = "disconnected";
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton gateway connection
|
||||
export const gateway = new GatewayConnection();
|
||||
|
||||
/**
|
||||
* Send a chat message to the gateway
|
||||
*/
|
||||
export async function chatSend(sessionKey: string, message: string): Promise<any> {
|
||||
return gateway.request("chat.send", {
|
||||
sessionKey,
|
||||
message,
|
||||
idempotencyKey: `dash-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chat history from the gateway
|
||||
*/
|
||||
export async function chatHistory(sessionKey: string, limit = 50): Promise<any> {
|
||||
return gateway.request("chat.history", { sessionKey, limit });
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort an in-progress chat response
|
||||
*/
|
||||
export async function chatAbort(sessionKey: string): Promise<any> {
|
||||
return gateway.request("chat.abort", { sessionKey });
|
||||
}
|
||||
|
||||
/**
|
||||
* List sessions from the gateway
|
||||
*/
|
||||
export async function sessionsList(limit = 50): Promise<any> {
|
||||
return gateway.request("sessions.list", { limit });
|
||||
}
|
||||
242
backend/src/lib/utils.test.ts
Normal file
242
backend/src/lib/utils.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { describe, test, expect, beforeAll } from "bun:test";
|
||||
import {
|
||||
computeNextDueDate,
|
||||
resetSubtasks,
|
||||
parseTaskIdentifier,
|
||||
isValidTaskStatus,
|
||||
isValidTaskPriority,
|
||||
isValidTaskSource,
|
||||
isValidRecurrenceFrequency,
|
||||
statusSortOrder,
|
||||
} from "./utils";
|
||||
import type { Subtask } from "../db/schema";
|
||||
|
||||
// ── computeNextDueDate ──────────────────────────────────────────────
|
||||
|
||||
describe("computeNextDueDate", () => {
|
||||
test("daily adds 1 day from now when no fromDate", () => {
|
||||
const before = new Date();
|
||||
const result = computeNextDueDate("daily");
|
||||
const after = new Date();
|
||||
// Should be roughly 1 day from now
|
||||
const diffMs = result.getTime() - before.getTime();
|
||||
const oneDayMs = 24 * 60 * 60 * 1000;
|
||||
expect(diffMs).toBeGreaterThanOrEqual(oneDayMs - 1000);
|
||||
expect(diffMs).toBeLessThanOrEqual(oneDayMs + 1000);
|
||||
});
|
||||
|
||||
test("weekly adds 7 days", () => {
|
||||
const before = new Date();
|
||||
const result = computeNextDueDate("weekly");
|
||||
const diffMs = result.getTime() - before.getTime();
|
||||
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
|
||||
expect(diffMs).toBeGreaterThanOrEqual(sevenDaysMs - 1000);
|
||||
expect(diffMs).toBeLessThanOrEqual(sevenDaysMs + 1000);
|
||||
});
|
||||
|
||||
test("biweekly adds 14 days", () => {
|
||||
const before = new Date();
|
||||
const result = computeNextDueDate("biweekly");
|
||||
const diffMs = result.getTime() - before.getTime();
|
||||
const fourteenDaysMs = 14 * 24 * 60 * 60 * 1000;
|
||||
expect(diffMs).toBeGreaterThanOrEqual(fourteenDaysMs - 1000);
|
||||
expect(diffMs).toBeLessThanOrEqual(fourteenDaysMs + 1000);
|
||||
});
|
||||
|
||||
test("monthly adds approximately 1 month", () => {
|
||||
const before = new Date();
|
||||
const result = computeNextDueDate("monthly");
|
||||
// Should be roughly 28-31 days from now
|
||||
const diffDays = (result.getTime() - before.getTime()) / (24 * 60 * 60 * 1000);
|
||||
expect(diffDays).toBeGreaterThanOrEqual(27);
|
||||
expect(diffDays).toBeLessThanOrEqual(32);
|
||||
});
|
||||
|
||||
test("uses fromDate when it is in the future", () => {
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 10); // 10 days from now
|
||||
const result = computeNextDueDate("daily", futureDate);
|
||||
// Should be futureDate + 1 day
|
||||
const expected = new Date(futureDate);
|
||||
expected.setDate(expected.getDate() + 1);
|
||||
expect(result.getDate()).toBe(expected.getDate());
|
||||
});
|
||||
|
||||
test("ignores fromDate when it is in the past", () => {
|
||||
const pastDate = new Date("2020-01-01");
|
||||
const before = new Date();
|
||||
const result = computeNextDueDate("daily", pastDate);
|
||||
// Should be ~1 day from now, not from 2020
|
||||
expect(result.getFullYear()).toBeGreaterThanOrEqual(before.getFullYear());
|
||||
});
|
||||
|
||||
test("handles null fromDate", () => {
|
||||
const before = new Date();
|
||||
const result = computeNextDueDate("weekly", null);
|
||||
const diffMs = result.getTime() - before.getTime();
|
||||
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
|
||||
expect(diffMs).toBeGreaterThanOrEqual(sevenDaysMs - 1000);
|
||||
});
|
||||
});
|
||||
|
||||
// ── resetSubtasks ───────────────────────────────────────────────────
|
||||
|
||||
describe("resetSubtasks", () => {
|
||||
test("resets all subtasks to uncompleted", () => {
|
||||
const subtasks: Subtask[] = [
|
||||
{ id: "st-1", title: "Do thing", completed: true, completedAt: "2025-01-01T00:00:00Z", createdAt: "2024-12-01T00:00:00Z" },
|
||||
{ id: "st-2", title: "Do other thing", completed: false, createdAt: "2024-12-01T00:00:00Z" },
|
||||
{ id: "st-3", title: "Done thing", completed: true, completedAt: "2025-01-15T00:00:00Z", createdAt: "2024-12-15T00:00:00Z" },
|
||||
];
|
||||
const result = resetSubtasks(subtasks);
|
||||
expect(result).toHaveLength(3);
|
||||
for (const s of result) {
|
||||
expect(s.completed).toBe(false);
|
||||
expect(s.completedAt).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves other fields", () => {
|
||||
const subtasks: Subtask[] = [
|
||||
{ id: "st-1", title: "My task", completed: true, completedAt: "2025-01-01T00:00:00Z", createdAt: "2024-12-01T00:00:00Z" },
|
||||
];
|
||||
const result = resetSubtasks(subtasks);
|
||||
expect(result[0].id).toBe("st-1");
|
||||
expect(result[0].title).toBe("My task");
|
||||
expect(result[0].createdAt).toBe("2024-12-01T00:00:00Z");
|
||||
});
|
||||
|
||||
test("handles empty array", () => {
|
||||
expect(resetSubtasks([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── parseTaskIdentifier ─────────────────────────────────────────────
|
||||
|
||||
describe("parseTaskIdentifier", () => {
|
||||
test("parses plain number", () => {
|
||||
const result = parseTaskIdentifier("42");
|
||||
expect(result).toEqual({ type: "number", value: 42 });
|
||||
});
|
||||
|
||||
test("parses HQ- prefixed number", () => {
|
||||
const result = parseTaskIdentifier("HQ-7");
|
||||
expect(result).toEqual({ type: "number", value: 7 });
|
||||
});
|
||||
|
||||
test("parses hq- prefixed number (case insensitive)", () => {
|
||||
const result = parseTaskIdentifier("hq-15");
|
||||
expect(result).toEqual({ type: "number", value: 15 });
|
||||
});
|
||||
|
||||
test("parses UUID", () => {
|
||||
const uuid = "550e8400-e29b-41d4-a716-446655440000";
|
||||
const result = parseTaskIdentifier(uuid);
|
||||
expect(result).toEqual({ type: "uuid", value: uuid });
|
||||
});
|
||||
|
||||
test("treats non-numeric strings as UUID", () => {
|
||||
const result = parseTaskIdentifier("abc123");
|
||||
expect(result).toEqual({ type: "uuid", value: "abc123" });
|
||||
});
|
||||
|
||||
test("treats mixed number-string as UUID", () => {
|
||||
const result = parseTaskIdentifier("12abc");
|
||||
expect(result).toEqual({ type: "uuid", value: "12abc" });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Validators ──────────────────────────────────────────────────────
|
||||
|
||||
describe("isValidTaskStatus", () => {
|
||||
test("accepts valid statuses", () => {
|
||||
expect(isValidTaskStatus("active")).toBe(true);
|
||||
expect(isValidTaskStatus("queued")).toBe(true);
|
||||
expect(isValidTaskStatus("blocked")).toBe(true);
|
||||
expect(isValidTaskStatus("completed")).toBe(true);
|
||||
expect(isValidTaskStatus("cancelled")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects invalid statuses", () => {
|
||||
expect(isValidTaskStatus("done")).toBe(false);
|
||||
expect(isValidTaskStatus("")).toBe(false);
|
||||
expect(isValidTaskStatus("ACTIVE")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidTaskPriority", () => {
|
||||
test("accepts valid priorities", () => {
|
||||
expect(isValidTaskPriority("critical")).toBe(true);
|
||||
expect(isValidTaskPriority("high")).toBe(true);
|
||||
expect(isValidTaskPriority("medium")).toBe(true);
|
||||
expect(isValidTaskPriority("low")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects invalid priorities", () => {
|
||||
expect(isValidTaskPriority("urgent")).toBe(false);
|
||||
expect(isValidTaskPriority("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidTaskSource", () => {
|
||||
test("accepts valid sources", () => {
|
||||
expect(isValidTaskSource("donovan")).toBe(true);
|
||||
expect(isValidTaskSource("hammer")).toBe(true);
|
||||
expect(isValidTaskSource("heartbeat")).toBe(true);
|
||||
expect(isValidTaskSource("cron")).toBe(true);
|
||||
expect(isValidTaskSource("other")).toBe(true);
|
||||
expect(isValidTaskSource("david")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects invalid sources", () => {
|
||||
expect(isValidTaskSource("system")).toBe(false);
|
||||
expect(isValidTaskSource("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidRecurrenceFrequency", () => {
|
||||
test("accepts valid frequencies", () => {
|
||||
expect(isValidRecurrenceFrequency("daily")).toBe(true);
|
||||
expect(isValidRecurrenceFrequency("weekly")).toBe(true);
|
||||
expect(isValidRecurrenceFrequency("biweekly")).toBe(true);
|
||||
expect(isValidRecurrenceFrequency("monthly")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects invalid frequencies", () => {
|
||||
expect(isValidRecurrenceFrequency("yearly")).toBe(false);
|
||||
expect(isValidRecurrenceFrequency("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── statusSortOrder ─────────────────────────────────────────────────
|
||||
|
||||
describe("statusSortOrder", () => {
|
||||
test("active sorts first", () => {
|
||||
expect(statusSortOrder("active")).toBe(0);
|
||||
});
|
||||
|
||||
test("cancelled sorts last among known statuses", () => {
|
||||
expect(statusSortOrder("cancelled")).toBe(4);
|
||||
});
|
||||
|
||||
test("maintains correct ordering", () => {
|
||||
const statuses = ["cancelled", "active", "blocked", "queued", "completed"];
|
||||
const sorted = [...statuses].sort(
|
||||
(a, b) => statusSortOrder(a) - statusSortOrder(b)
|
||||
);
|
||||
expect(sorted).toEqual([
|
||||
"active",
|
||||
"queued",
|
||||
"blocked",
|
||||
"completed",
|
||||
"cancelled",
|
||||
]);
|
||||
});
|
||||
|
||||
test("unknown status gets highest sort value", () => {
|
||||
expect(statusSortOrder("unknown")).toBe(5);
|
||||
expect(statusSortOrder("unknown")).toBeGreaterThan(
|
||||
statusSortOrder("cancelled")
|
||||
);
|
||||
});
|
||||
});
|
||||
101
backend/src/lib/utils.ts
Normal file
101
backend/src/lib/utils.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Pure utility functions extracted for testability.
|
||||
* No database or external dependencies.
|
||||
*/
|
||||
|
||||
import type { RecurrenceFrequency, Subtask } from "../db/schema";
|
||||
|
||||
/**
|
||||
* Compute the next due date for a recurring task based on frequency.
|
||||
*/
|
||||
export function computeNextDueDate(
|
||||
frequency: RecurrenceFrequency,
|
||||
fromDate?: Date | null
|
||||
): Date {
|
||||
const base =
|
||||
fromDate && fromDate > new Date() ? new Date(fromDate) : new Date();
|
||||
switch (frequency) {
|
||||
case "daily":
|
||||
base.setDate(base.getDate() + 1);
|
||||
break;
|
||||
case "weekly":
|
||||
base.setDate(base.getDate() + 7);
|
||||
break;
|
||||
case "biweekly":
|
||||
base.setDate(base.getDate() + 14);
|
||||
break;
|
||||
case "monthly":
|
||||
base.setMonth(base.getMonth() + 1);
|
||||
break;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset subtasks for a new recurrence instance (uncheck all).
|
||||
*/
|
||||
export function resetSubtasks(subtasks: Subtask[]): Subtask[] {
|
||||
return subtasks.map((s) => ({
|
||||
...s,
|
||||
completed: false,
|
||||
completedAt: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a task identifier - could be a UUID, a number, or "HQ-<number>".
|
||||
* Returns { type: "number", value: number } or { type: "uuid", value: string }.
|
||||
*/
|
||||
export function parseTaskIdentifier(idOrNumber: string):
|
||||
| { type: "number"; value: number }
|
||||
| { type: "uuid"; value: string } {
|
||||
const cleaned = idOrNumber.replace(/^HQ-/i, "");
|
||||
const asNumber = parseInt(cleaned, 10);
|
||||
if (!isNaN(asNumber) && String(asNumber) === cleaned) {
|
||||
return { type: "number", value: asNumber };
|
||||
}
|
||||
return { type: "uuid", value: cleaned };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a status string is a valid task status.
|
||||
*/
|
||||
export function isValidTaskStatus(status: string): boolean {
|
||||
return ["active", "queued", "blocked", "completed", "cancelled"].includes(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a priority string is a valid task priority.
|
||||
*/
|
||||
export function isValidTaskPriority(priority: string): boolean {
|
||||
return ["critical", "high", "medium", "low"].includes(priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a source string is a valid task source.
|
||||
*/
|
||||
export function isValidTaskSource(source: string): boolean {
|
||||
return ["donovan", "david", "hammer", "heartbeat", "cron", "other"].includes(source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a recurrence frequency string.
|
||||
*/
|
||||
export function isValidRecurrenceFrequency(freq: string): boolean {
|
||||
return ["daily", "weekly", "biweekly", "monthly"].includes(freq);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort status values by priority order (active first).
|
||||
* Returns a numeric sort key.
|
||||
*/
|
||||
export function statusSortOrder(status: string): number {
|
||||
const order: Record<string, number> = {
|
||||
active: 0,
|
||||
queued: 1,
|
||||
blocked: 2,
|
||||
completed: 3,
|
||||
cancelled: 4,
|
||||
};
|
||||
return order[status] ?? 5;
|
||||
}
|
||||
105
backend/src/routes/activity.ts
Normal file
105
backend/src/routes/activity.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { db } from "../db";
|
||||
import { tasks, taskComments } from "../db/schema";
|
||||
import { desc, sql } from "drizzle-orm";
|
||||
import { auth } from "../lib/auth";
|
||||
|
||||
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
|
||||
|
||||
async function requireSessionOrBearer(request: Request, headers: Record<string, string | undefined>) {
|
||||
const authHeader = headers["authorization"];
|
||||
if (authHeader === `Bearer ${BEARER_TOKEN}`) return;
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
if (session?.user) return;
|
||||
} catch {}
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
export interface ActivityFeedItem {
|
||||
type: "progress" | "comment";
|
||||
timestamp: string;
|
||||
taskId: string;
|
||||
taskNumber: number | null;
|
||||
taskTitle: string;
|
||||
taskStatus: string;
|
||||
// For progress notes
|
||||
note?: string;
|
||||
// For comments
|
||||
commentId?: string;
|
||||
authorName?: string;
|
||||
authorId?: string | null;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export const activityRoutes = new Elysia({ prefix: "/api/activity" })
|
||||
.onError(({ error, set }) => {
|
||||
const msg = (error as any)?.message || String(error);
|
||||
if (msg === "Unauthorized") {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized" };
|
||||
}
|
||||
set.status = 500;
|
||||
return { error: "Internal server error" };
|
||||
})
|
||||
|
||||
// GET /api/activity — unified feed of progress notes + comments
|
||||
.get("/", async ({ request, headers, query }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
|
||||
const limit = Math.min(Number(query.limit) || 50, 200);
|
||||
|
||||
// Fetch all tasks with progress notes
|
||||
const allTasks = await db.select().from(tasks);
|
||||
|
||||
// Collect progress note items
|
||||
const items: ActivityFeedItem[] = [];
|
||||
for (const task of allTasks) {
|
||||
const notes = (task.progressNotes || []) as { timestamp: string; note: string }[];
|
||||
for (const note of notes) {
|
||||
items.push({
|
||||
type: "progress",
|
||||
timestamp: note.timestamp,
|
||||
taskId: task.id,
|
||||
taskNumber: task.taskNumber,
|
||||
taskTitle: task.title,
|
||||
taskStatus: task.status,
|
||||
note: note.note,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all comments
|
||||
const allComments = await db
|
||||
.select()
|
||||
.from(taskComments)
|
||||
.orderBy(desc(taskComments.createdAt));
|
||||
|
||||
// Build task lookup for comment items
|
||||
const taskMap = new Map(allTasks.map(t => [t.id, t]));
|
||||
|
||||
for (const comment of allComments) {
|
||||
const task = taskMap.get(comment.taskId);
|
||||
if (!task) continue;
|
||||
items.push({
|
||||
type: "comment",
|
||||
timestamp: comment.createdAt.toISOString(),
|
||||
taskId: task.id,
|
||||
taskNumber: task.taskNumber,
|
||||
taskTitle: task.title,
|
||||
taskStatus: task.status,
|
||||
commentId: comment.id,
|
||||
authorName: comment.authorName,
|
||||
authorId: comment.authorId,
|
||||
content: comment.content,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by timestamp descending, take limit
|
||||
items.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
|
||||
return {
|
||||
items: items.slice(0, limit),
|
||||
total: items.length,
|
||||
};
|
||||
});
|
||||
@@ -1,269 +0,0 @@
|
||||
/**
|
||||
* Chat routes - WebSocket relay + REST fallback for dashboard chat
|
||||
*
|
||||
* WebSocket: /api/chat/ws - Real-time bidirectional relay to gateway
|
||||
* REST: /api/chat/send, /api/chat/history, /api/chat/sessions - Fallback endpoints
|
||||
*/
|
||||
import { Elysia } from "elysia";
|
||||
import { auth } from "../lib/auth";
|
||||
import { gateway, chatSend, chatHistory, chatAbort, sessionsList } from "../lib/gateway-relay";
|
||||
|
||||
// Track active WebSocket client connections
|
||||
const activeClients = new Map<string, {
|
||||
ws: any;
|
||||
userId: string;
|
||||
sessionKeys: Set<string>;
|
||||
}>();
|
||||
|
||||
export const chatRoutes = new Elysia()
|
||||
// WebSocket endpoint for real-time chat relay
|
||||
.ws("/api/chat/ws", {
|
||||
open(ws) {
|
||||
const clientId = `client-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
(ws.data as any).__clientId = clientId;
|
||||
(ws.data as any).__authenticated = false;
|
||||
console.log(`[chat-ws] Client connected: ${clientId}`);
|
||||
},
|
||||
|
||||
async message(ws, rawMsg) {
|
||||
const clientId = (ws.data as any).__clientId || "unknown";
|
||||
|
||||
let msg: any;
|
||||
try {
|
||||
msg = typeof rawMsg === "string" ? JSON.parse(rawMsg) : rawMsg;
|
||||
} catch {
|
||||
ws.send(JSON.stringify({ type: "error", error: "Invalid JSON" }));
|
||||
return;
|
||||
}
|
||||
|
||||
// First message must be auth
|
||||
if (!(ws.data as any).__authenticated) {
|
||||
if (msg.type !== "auth") {
|
||||
ws.send(JSON.stringify({ type: "error", error: "Must authenticate first" }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate session cookie or token
|
||||
const session = await validateAuth(msg);
|
||||
if (!session) {
|
||||
ws.send(JSON.stringify({ type: "error", error: "Authentication failed" }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
(ws.data as any).__authenticated = true;
|
||||
(ws.data as any).__userId = session.user.id;
|
||||
(ws.data as any).__userName = session.user.name || session.user.email;
|
||||
|
||||
// Register client
|
||||
activeClients.set(clientId, {
|
||||
ws,
|
||||
userId: session.user.id,
|
||||
sessionKeys: new Set(),
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: "auth_ok",
|
||||
user: { id: session.user.id, name: session.user.name },
|
||||
gatewayConnected: gateway.isConnected(),
|
||||
}));
|
||||
|
||||
console.log(`[chat-ws] Client authenticated: ${clientId} (${session.user.name || session.user.email})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle authenticated messages
|
||||
try {
|
||||
await handleClientMessage(clientId, ws, msg);
|
||||
} catch (e: any) {
|
||||
ws.send(JSON.stringify({
|
||||
type: "error",
|
||||
id: msg.id,
|
||||
error: e.message || "Internal error",
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
close(ws) {
|
||||
const clientId = (ws.data as any).__clientId || "unknown";
|
||||
activeClients.delete(clientId);
|
||||
console.log(`[chat-ws] Client disconnected: ${clientId}`);
|
||||
},
|
||||
})
|
||||
|
||||
// REST: Send a chat message
|
||||
.post("/api/chat/send", async ({ request, body }) => {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
if (!session?.user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
||||
}
|
||||
|
||||
const { sessionKey, message } = body as { sessionKey: string; message: string };
|
||||
if (!sessionKey || !message) {
|
||||
return new Response(JSON.stringify({ error: "sessionKey and message required" }), { status: 400 });
|
||||
}
|
||||
|
||||
if (!gateway.isConnected()) {
|
||||
return new Response(JSON.stringify({ error: "Gateway not connected" }), { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await chatSend(sessionKey, message);
|
||||
return { ok: true, result };
|
||||
} catch (e: any) {
|
||||
return new Response(JSON.stringify({ error: e.message }), { status: 500 });
|
||||
}
|
||||
})
|
||||
|
||||
// REST: Get chat history
|
||||
.get("/api/chat/history/:sessionKey", async ({ request, params }) => {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
if (!session?.user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
||||
}
|
||||
|
||||
if (!gateway.isConnected()) {
|
||||
return new Response(JSON.stringify({ error: "Gateway not connected" }), { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await chatHistory(params.sessionKey);
|
||||
return { ok: true, ...result };
|
||||
} catch (e: any) {
|
||||
return new Response(JSON.stringify({ error: e.message }), { status: 500 });
|
||||
}
|
||||
})
|
||||
|
||||
// REST: List sessions
|
||||
.get("/api/chat/sessions", async ({ request }) => {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
if (!session?.user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
||||
}
|
||||
|
||||
if (!gateway.isConnected()) {
|
||||
return new Response(JSON.stringify({ error: "Gateway not connected" }), { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sessionsList();
|
||||
return { ok: true, ...result };
|
||||
} catch (e: any) {
|
||||
return new Response(JSON.stringify({ error: e.message }), { status: 500 });
|
||||
}
|
||||
})
|
||||
|
||||
// REST: Gateway connection status
|
||||
.get("/api/chat/status", async ({ request }) => {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
if (!session?.user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
||||
}
|
||||
|
||||
return {
|
||||
gatewayConnected: gateway.isConnected(),
|
||||
activeClients: activeClients.size,
|
||||
};
|
||||
});
|
||||
|
||||
// Validate auth from WebSocket auth message
|
||||
async function validateAuth(msg: any): Promise<any> {
|
||||
// Support cookie-based auth (pass cookie string)
|
||||
if (msg.cookie) {
|
||||
try {
|
||||
// Create a fake request with the cookie header for BetterAuth
|
||||
const headers = new Headers();
|
||||
headers.set("cookie", msg.cookie);
|
||||
const session = await auth.api.getSession({ headers });
|
||||
return session;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Support bearer token auth
|
||||
if (msg.token) {
|
||||
try {
|
||||
const headers = new Headers();
|
||||
headers.set("authorization", `Bearer ${msg.token}`);
|
||||
const session = await auth.api.getSession({ headers });
|
||||
return session;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle messages from authenticated WebSocket clients
|
||||
async function handleClientMessage(clientId: string, ws: any, msg: any) {
|
||||
const client = activeClients.get(clientId);
|
||||
if (!client) return;
|
||||
|
||||
switch (msg.type) {
|
||||
case "chat.send": {
|
||||
const { sessionKey, message } = msg;
|
||||
if (!sessionKey || !message) {
|
||||
ws.send(JSON.stringify({ type: "error", id: msg.id, error: "sessionKey and message required" }));
|
||||
return;
|
||||
}
|
||||
client.sessionKeys.add(sessionKey);
|
||||
const result = await chatSend(sessionKey, message);
|
||||
ws.send(JSON.stringify({ type: "res", id: msg.id, ok: true, payload: result }));
|
||||
break;
|
||||
}
|
||||
|
||||
case "chat.history": {
|
||||
const { sessionKey, limit } = msg;
|
||||
if (!sessionKey) {
|
||||
ws.send(JSON.stringify({ type: "error", id: msg.id, error: "sessionKey required" }));
|
||||
return;
|
||||
}
|
||||
client.sessionKeys.add(sessionKey);
|
||||
const result = await chatHistory(sessionKey, limit);
|
||||
ws.send(JSON.stringify({ type: "res", id: msg.id, ok: true, payload: result }));
|
||||
break;
|
||||
}
|
||||
|
||||
case "chat.abort": {
|
||||
const { sessionKey } = msg;
|
||||
if (!sessionKey) {
|
||||
ws.send(JSON.stringify({ type: "error", id: msg.id, error: "sessionKey required" }));
|
||||
return;
|
||||
}
|
||||
const result = await chatAbort(sessionKey);
|
||||
ws.send(JSON.stringify({ type: "res", id: msg.id, ok: true, payload: result }));
|
||||
break;
|
||||
}
|
||||
|
||||
case "sessions.list": {
|
||||
const result = await sessionsList(msg.limit);
|
||||
ws.send(JSON.stringify({ type: "res", id: msg.id, ok: true, payload: result }));
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
ws.send(JSON.stringify({ type: "error", id: msg.id, error: `Unknown message type: ${msg.type}` }));
|
||||
}
|
||||
}
|
||||
|
||||
// Forward gateway events to relevant WebSocket clients
|
||||
gateway.onEvent((msg: any) => {
|
||||
if (msg.type !== "event") return;
|
||||
|
||||
const payload = msg.payload || {};
|
||||
const sessionKey = payload.sessionKey;
|
||||
|
||||
for (const [, client] of activeClients) {
|
||||
// Forward to clients subscribed to this session key, or broadcast if no key
|
||||
if (!sessionKey || client.sessionKeys.has(sessionKey)) {
|
||||
try {
|
||||
client.ws.send(JSON.stringify(msg));
|
||||
} catch {
|
||||
// Client disconnected, will be cleaned up
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
127
backend/src/routes/comments.ts
Normal file
127
backend/src/routes/comments.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { db } from "../db";
|
||||
import { taskComments, tasks } from "../db/schema";
|
||||
import { eq, desc, asc } from "drizzle-orm";
|
||||
import { auth } from "../lib/auth";
|
||||
import { parseTaskIdentifier } from "../lib/utils";
|
||||
|
||||
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
|
||||
|
||||
async function requireSessionOrBearer(request: Request, headers: Record<string, string | undefined>) {
|
||||
const authHeader = headers["authorization"];
|
||||
if (authHeader === `Bearer ${BEARER_TOKEN}`) {
|
||||
return { userId: "hammer", userName: "Hammer" };
|
||||
}
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
if (session?.user) {
|
||||
return { userId: session.user.id, userName: session.user.name || session.user.email };
|
||||
}
|
||||
} catch {}
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
async function resolveTaskId(idOrNumber: string): Promise<string | null> {
|
||||
const parsed = parseTaskIdentifier(idOrNumber);
|
||||
let result;
|
||||
if (parsed.type === "number") {
|
||||
result = await db.select({ id: tasks.id }).from(tasks).where(eq(tasks.taskNumber, parsed.value));
|
||||
} else {
|
||||
result = await db.select({ id: tasks.id }).from(tasks).where(eq(tasks.id, parsed.value));
|
||||
}
|
||||
return result[0]?.id || null;
|
||||
}
|
||||
|
||||
export const commentRoutes = new Elysia({ prefix: "/api/tasks" })
|
||||
.onError(({ error, set }) => {
|
||||
const msg = (error as any)?.message || String(error);
|
||||
if (msg === "Unauthorized") {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized" };
|
||||
}
|
||||
if (msg === "Task not found") {
|
||||
set.status = 404;
|
||||
return { error: "Task not found" };
|
||||
}
|
||||
set.status = 500;
|
||||
return { error: "Internal server error" };
|
||||
})
|
||||
|
||||
// GET comments for a task
|
||||
.get(
|
||||
"/:id/comments",
|
||||
async ({ params, request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const taskId = await resolveTaskId(params.id);
|
||||
if (!taskId) throw new Error("Task not found");
|
||||
|
||||
const comments = await db
|
||||
.select()
|
||||
.from(taskComments)
|
||||
.where(eq(taskComments.taskId, taskId))
|
||||
.orderBy(asc(taskComments.createdAt));
|
||||
|
||||
return comments;
|
||||
},
|
||||
{ params: t.Object({ id: t.String() }) }
|
||||
)
|
||||
|
||||
// POST add comment to a task
|
||||
.post(
|
||||
"/:id/comments",
|
||||
async ({ params, body, request, headers }) => {
|
||||
const user = await requireSessionOrBearer(request, headers);
|
||||
const taskId = await resolveTaskId(params.id);
|
||||
if (!taskId) throw new Error("Task not found");
|
||||
|
||||
const comment = await db
|
||||
.insert(taskComments)
|
||||
.values({
|
||||
taskId,
|
||||
authorId: user.userId,
|
||||
authorName: body.authorName || user.userName,
|
||||
content: body.content,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return comment[0];
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
body: t.Object({
|
||||
content: t.String(),
|
||||
authorName: t.Optional(t.String()),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
// DELETE a comment
|
||||
.delete(
|
||||
"/:id/comments/:commentId",
|
||||
async ({ params, request, headers }) => {
|
||||
const user = await requireSessionOrBearer(request, headers);
|
||||
const taskId = await resolveTaskId(params.id);
|
||||
if (!taskId) throw new Error("Task not found");
|
||||
|
||||
// Only allow deleting own comments (or bearer token = admin)
|
||||
const comment = await db
|
||||
.select()
|
||||
.from(taskComments)
|
||||
.where(eq(taskComments.id, params.commentId));
|
||||
|
||||
if (!comment[0]) throw new Error("Task not found");
|
||||
if (comment[0].taskId !== taskId) throw new Error("Task not found");
|
||||
|
||||
// Bearer token can delete any, otherwise must be author
|
||||
const authHeader = headers["authorization"];
|
||||
if (authHeader !== `Bearer ${BEARER_TOKEN}` && comment[0].authorId !== user.userId) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
await db.delete(taskComments).where(eq(taskComments.id, params.commentId));
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String(), commentId: t.String() }),
|
||||
}
|
||||
);
|
||||
188
backend/src/routes/security.ts
Normal file
188
backend/src/routes/security.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { db } from "../db";
|
||||
import { securityAudits } from "../db/schema";
|
||||
import { eq, asc, and } from "drizzle-orm";
|
||||
import { auth } from "../lib/auth";
|
||||
|
||||
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
|
||||
|
||||
async function requireSessionOrBearer(
|
||||
request: Request,
|
||||
headers: Record<string, string | undefined>
|
||||
) {
|
||||
const authHeader = headers["authorization"];
|
||||
if (authHeader === `Bearer ${BEARER_TOKEN}`) return;
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
if (session) return;
|
||||
} catch {}
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const findingSchema = t.Object({
|
||||
id: t.String(),
|
||||
status: t.Union([
|
||||
t.Literal("strong"),
|
||||
t.Literal("needs_improvement"),
|
||||
t.Literal("critical"),
|
||||
]),
|
||||
title: t.String(),
|
||||
description: t.String(),
|
||||
recommendation: t.String(),
|
||||
});
|
||||
|
||||
export const securityRoutes = new Elysia({ prefix: "/api/security" })
|
||||
.onError(({ error, set }) => {
|
||||
const msg = (error as any)?.message || String(error);
|
||||
if (msg === "Unauthorized") {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized" };
|
||||
}
|
||||
if (msg === "Audit not found") {
|
||||
set.status = 404;
|
||||
return { error: "Audit not found" };
|
||||
}
|
||||
console.error("Security route error:", msg);
|
||||
set.status = 500;
|
||||
return { error: "Internal server error" };
|
||||
})
|
||||
|
||||
// GET all audits
|
||||
.get("/", async ({ request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const all = await db
|
||||
.select()
|
||||
.from(securityAudits)
|
||||
.orderBy(asc(securityAudits.projectName), asc(securityAudits.category));
|
||||
return all;
|
||||
})
|
||||
|
||||
// GET summary (aggregate scores per project)
|
||||
.get("/summary", async ({ request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const all = await db
|
||||
.select()
|
||||
.from(securityAudits)
|
||||
.orderBy(asc(securityAudits.projectName));
|
||||
|
||||
const projectMap: Record<
|
||||
string,
|
||||
{ scores: number[]; categories: number; lastAudited: string }
|
||||
> = {};
|
||||
|
||||
for (const audit of all) {
|
||||
if (!projectMap[audit.projectName]) {
|
||||
projectMap[audit.projectName] = {
|
||||
scores: [],
|
||||
categories: 0,
|
||||
lastAudited: audit.lastAudited.toISOString(),
|
||||
};
|
||||
}
|
||||
projectMap[audit.projectName].scores.push(audit.score);
|
||||
projectMap[audit.projectName].categories++;
|
||||
const auditDate = audit.lastAudited.toISOString();
|
||||
if (auditDate > projectMap[audit.projectName].lastAudited) {
|
||||
projectMap[audit.projectName].lastAudited = auditDate;
|
||||
}
|
||||
}
|
||||
|
||||
const summary = Object.entries(projectMap).map(([name, data]) => ({
|
||||
projectName: name,
|
||||
averageScore: Math.round(
|
||||
data.scores.reduce((a, b) => a + b, 0) / data.scores.length
|
||||
),
|
||||
categoriesAudited: data.categories,
|
||||
lastAudited: data.lastAudited,
|
||||
}));
|
||||
|
||||
return summary;
|
||||
})
|
||||
|
||||
// GET audits for a specific project
|
||||
.get(
|
||||
"/project/:projectName",
|
||||
async ({ params, request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const audits = await db
|
||||
.select()
|
||||
.from(securityAudits)
|
||||
.where(eq(securityAudits.projectName, decodeURIComponent(params.projectName)))
|
||||
.orderBy(asc(securityAudits.category));
|
||||
return audits;
|
||||
},
|
||||
{ params: t.Object({ projectName: t.String() }) }
|
||||
)
|
||||
|
||||
// POST create audit entry
|
||||
.post(
|
||||
"/",
|
||||
async ({ body, request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const newAudit = await db
|
||||
.insert(securityAudits)
|
||||
.values({
|
||||
projectName: body.projectName,
|
||||
category: body.category,
|
||||
findings: body.findings || [],
|
||||
score: body.score,
|
||||
lastAudited: new Date(),
|
||||
})
|
||||
.returning();
|
||||
return newAudit[0];
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
projectName: t.String(),
|
||||
category: t.String(),
|
||||
findings: t.Optional(t.Array(findingSchema)),
|
||||
score: t.Number(),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
// PATCH update audit entry
|
||||
.patch(
|
||||
"/:id",
|
||||
async ({ params, body, request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const updates: Record<string, any> = { updatedAt: new Date() };
|
||||
if (body.projectName !== undefined) updates.projectName = body.projectName;
|
||||
if (body.category !== undefined) updates.category = body.category;
|
||||
if (body.findings !== undefined) updates.findings = body.findings;
|
||||
if (body.score !== undefined) updates.score = body.score;
|
||||
if (body.refreshAuditDate) updates.lastAudited = new Date();
|
||||
|
||||
const updated = await db
|
||||
.update(securityAudits)
|
||||
.set(updates)
|
||||
.where(eq(securityAudits.id, params.id))
|
||||
.returning();
|
||||
if (!updated.length) throw new Error("Audit not found");
|
||||
return updated[0];
|
||||
},
|
||||
{
|
||||
params: t.Object({ id: t.String() }),
|
||||
body: t.Object({
|
||||
projectName: t.Optional(t.String()),
|
||||
category: t.Optional(t.String()),
|
||||
findings: t.Optional(t.Array(findingSchema)),
|
||||
score: t.Optional(t.Number()),
|
||||
refreshAuditDate: t.Optional(t.Boolean()),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
// DELETE audit entry
|
||||
.delete(
|
||||
"/:id",
|
||||
async ({ params, request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
const deleted = await db
|
||||
.delete(securityAudits)
|
||||
.where(eq(securityAudits.id, params.id))
|
||||
.returning();
|
||||
if (!deleted.length) throw new Error("Audit not found");
|
||||
return { success: true };
|
||||
},
|
||||
{ params: t.Object({ id: t.String() }) }
|
||||
);
|
||||
197
backend/src/routes/summaries.ts
Normal file
197
backend/src/routes/summaries.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { db } from "../db";
|
||||
import { dailySummaries } from "../db/schema";
|
||||
import { desc, eq, sql } from "drizzle-orm";
|
||||
import { auth } from "../lib/auth";
|
||||
|
||||
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
|
||||
|
||||
async function requireSessionOrBearer(
|
||||
request: Request,
|
||||
headers: Record<string, string | undefined>
|
||||
) {
|
||||
const authHeader = headers["authorization"];
|
||||
if (authHeader === `Bearer ${BEARER_TOKEN}`) return;
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
if (session?.user) return;
|
||||
} catch {}
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
export const summaryRoutes = new Elysia({ prefix: "/api/summaries" })
|
||||
.onError(({ error, set }) => {
|
||||
const msg = (error as any)?.message || String(error);
|
||||
if (msg === "Unauthorized") {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized" };
|
||||
}
|
||||
if (msg === "Not found") {
|
||||
set.status = 404;
|
||||
return { error: "Summary not found" };
|
||||
}
|
||||
set.status = 500;
|
||||
return { error: "Internal server error" };
|
||||
})
|
||||
|
||||
// GET /api/summaries — list all summaries (paginated, newest first)
|
||||
.get("/", async ({ request, headers, query }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
|
||||
const page = Math.max(1, Number(query.page) || 1);
|
||||
const limit = Math.min(Number(query.limit) || 50, 200);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
try {
|
||||
const [items, countResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(dailySummaries)
|
||||
.orderBy(desc(dailySummaries.date))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(dailySummaries),
|
||||
]);
|
||||
|
||||
const total = Number(countResult[0]?.count ?? 0);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
} catch (e: any) {
|
||||
console.error("Error fetching summaries:", e?.message || e);
|
||||
throw e;
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/summaries/dates — list all dates that have summaries (for calendar)
|
||||
.get("/dates", async ({ request, headers }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
|
||||
const rows = await db
|
||||
.select({ date: dailySummaries.date })
|
||||
.from(dailySummaries)
|
||||
.orderBy(desc(dailySummaries.date));
|
||||
|
||||
return { dates: rows.map((r) => r.date) };
|
||||
})
|
||||
|
||||
// GET /api/summaries/:date — get summary for specific date
|
||||
.get("/:date", async ({ request, headers, params }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
|
||||
const { date } = params;
|
||||
// Validate date format
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
throw new Error("Not found");
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(dailySummaries)
|
||||
.where(eq(dailySummaries.date, date))
|
||||
.limit(1);
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error("Not found");
|
||||
}
|
||||
|
||||
return result[0];
|
||||
})
|
||||
|
||||
// POST /api/summaries — create/upsert summary for a date
|
||||
.post("/", async ({ request, headers, body }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
|
||||
const { date, content, highlights, stats } = body as {
|
||||
date: string;
|
||||
content: string;
|
||||
highlights?: { text: string }[];
|
||||
stats?: Record<string, number>;
|
||||
};
|
||||
|
||||
if (!date || !content) {
|
||||
throw new Error("date and content are required");
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
throw new Error("date must be YYYY-MM-DD format");
|
||||
}
|
||||
|
||||
// Upsert: insert or update on conflict
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(dailySummaries)
|
||||
.where(eq(dailySummaries.date, date))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
const updated = await db
|
||||
.update(dailySummaries)
|
||||
.set({
|
||||
content,
|
||||
highlights: highlights || existing[0].highlights,
|
||||
stats: stats || existing[0].stats,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(dailySummaries.date, date))
|
||||
.returning();
|
||||
return updated[0];
|
||||
}
|
||||
|
||||
const inserted = await db
|
||||
.insert(dailySummaries)
|
||||
.values({
|
||||
date,
|
||||
content,
|
||||
highlights: highlights || [],
|
||||
stats: stats || {},
|
||||
})
|
||||
.returning();
|
||||
|
||||
return inserted[0];
|
||||
})
|
||||
|
||||
// PATCH /api/summaries/:date — update existing summary
|
||||
.patch("/:date", async ({ request, headers, params, body }) => {
|
||||
await requireSessionOrBearer(request, headers);
|
||||
|
||||
const { date } = params;
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
throw new Error("Not found");
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(dailySummaries)
|
||||
.where(eq(dailySummaries.date, date))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
throw new Error("Not found");
|
||||
}
|
||||
|
||||
const updates: Record<string, any> = { updatedAt: new Date() };
|
||||
const { content, highlights, stats } = body as {
|
||||
content?: string;
|
||||
highlights?: { text: string }[];
|
||||
stats?: Record<string, number>;
|
||||
};
|
||||
|
||||
if (content !== undefined) updates.content = content;
|
||||
if (highlights !== undefined) updates.highlights = highlights;
|
||||
if (stats !== undefined) updates.stats = stats;
|
||||
|
||||
const updated = await db
|
||||
.update(dailySummaries)
|
||||
.set(updates)
|
||||
.where(eq(dailySummaries.date, date))
|
||||
.returning();
|
||||
|
||||
return updated[0];
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { db } from "../db";
|
||||
import { tasks, type ProgressNote, type Subtask, type Recurrence, type RecurrenceFrequency } from "../db/schema";
|
||||
import { eq, asc, desc, sql, inArray, or } from "drizzle-orm";
|
||||
import { auth } from "../lib/auth";
|
||||
import { computeNextDueDate, resetSubtasks, parseTaskIdentifier } from "../lib/utils";
|
||||
|
||||
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
|
||||
const CLAWDBOT_HOOK_URL = process.env.CLAWDBOT_HOOK_URL || "https://hammer.donovankelly.xyz/hooks/agent";
|
||||
@@ -41,26 +42,6 @@ async function notifyTaskActivated(task: { id: string; title: string; descriptio
|
||||
}
|
||||
}
|
||||
|
||||
// Compute the next due date for a recurring task
|
||||
function computeNextDueDate(frequency: RecurrenceFrequency, fromDate?: Date | null): Date {
|
||||
const base = fromDate && fromDate > new Date() ? new Date(fromDate) : new Date();
|
||||
switch (frequency) {
|
||||
case "daily":
|
||||
base.setDate(base.getDate() + 1);
|
||||
break;
|
||||
case "weekly":
|
||||
base.setDate(base.getDate() + 7);
|
||||
break;
|
||||
case "biweekly":
|
||||
base.setDate(base.getDate() + 14);
|
||||
break;
|
||||
case "monthly":
|
||||
base.setMonth(base.getMonth() + 1);
|
||||
break;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
// Create the next instance of a recurring task
|
||||
async function spawnNextRecurrence(completedTask: typeof tasks.$inferSelect) {
|
||||
const recurrence = completedTask.recurrence as Recurrence | null;
|
||||
@@ -96,11 +77,7 @@ async function spawnNextRecurrence(completedTask: typeof tasks.$inferSelect) {
|
||||
estimatedHours: completedTask.estimatedHours,
|
||||
tags: completedTask.tags,
|
||||
recurrence: recurrence,
|
||||
subtasks: (completedTask.subtasks as Subtask[] || []).map(s => ({
|
||||
...s,
|
||||
completed: false,
|
||||
completedAt: undefined,
|
||||
})),
|
||||
subtasks: resetSubtasks(completedTask.subtasks as Subtask[] || []),
|
||||
progressNotes: [],
|
||||
})
|
||||
.returning();
|
||||
@@ -161,17 +138,12 @@ async function requireAdmin(request: Request, headers: Record<string, string | u
|
||||
|
||||
// Resolve a task by UUID or sequential number (e.g. "5" or "HQ-5")
|
||||
async function resolveTask(idOrNumber: string) {
|
||||
// Strip "HQ-" prefix if present
|
||||
const cleaned = idOrNumber.replace(/^HQ-/i, "");
|
||||
const asNumber = parseInt(cleaned, 10);
|
||||
|
||||
const parsed = parseTaskIdentifier(idOrNumber);
|
||||
let result;
|
||||
if (!isNaN(asNumber) && String(asNumber) === cleaned) {
|
||||
// Lookup by task_number
|
||||
result = await db.select().from(tasks).where(eq(tasks.taskNumber, asNumber));
|
||||
if (parsed.type === "number") {
|
||||
result = await db.select().from(tasks).where(eq(tasks.taskNumber, parsed.value));
|
||||
} else {
|
||||
// Lookup by UUID
|
||||
result = await db.select().from(tasks).where(eq(tasks.id, cleaned));
|
||||
result = await db.select().from(tasks).where(eq(tasks.id, parsed.value));
|
||||
}
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
280
backend/src/routes/todos.ts
Normal file
280
backend/src/routes/todos.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { db } from "../db";
|
||||
import { todos } from "../db/schema";
|
||||
import { eq, and, asc, desc, sql } from "drizzle-orm";
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { auth } from "../lib/auth";
|
||||
|
||||
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
|
||||
|
||||
async function requireSessionOrBearer(request: Request, headers: Record<string, string | undefined>) {
|
||||
const authHeader = headers["authorization"];
|
||||
if (authHeader === `Bearer ${BEARER_TOKEN}`) {
|
||||
// Return a default user ID for bearer token access
|
||||
return { userId: "bearer" };
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
if (session?.user) return { userId: session.user.id };
|
||||
} catch {}
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
export const todoRoutes = new Elysia({ prefix: "/api/todos" })
|
||||
.onError(({ error, set }) => {
|
||||
const msg = (error as any)?.message || String(error);
|
||||
if (msg === "Unauthorized") {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized" };
|
||||
}
|
||||
if (msg === "Not found") {
|
||||
set.status = 404;
|
||||
return { error: "Not found" };
|
||||
}
|
||||
console.error("Todo route error:", msg);
|
||||
set.status = 500;
|
||||
return { error: "Internal server error", debug: msg };
|
||||
})
|
||||
|
||||
// Debug endpoint - test DB connectivity for todos table
|
||||
.get("/debug", async () => {
|
||||
try {
|
||||
const result = await db.execute(sql`SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'todos')`);
|
||||
const enumResult = await db.execute(sql`SELECT EXISTS (SELECT FROM pg_type WHERE typname = 'todo_priority')`);
|
||||
return {
|
||||
todosTableExists: result,
|
||||
todoPriorityEnumExists: enumResult,
|
||||
dbConnected: true
|
||||
};
|
||||
} catch (e: any) {
|
||||
return { error: e.message, dbConnected: false };
|
||||
}
|
||||
})
|
||||
|
||||
// GET all todos for current user
|
||||
.get("/", async ({ request, headers, query }) => {
|
||||
const { userId } = await requireSessionOrBearer(request, headers);
|
||||
|
||||
const conditions = [eq(todos.userId, userId)];
|
||||
|
||||
// Filter by completion
|
||||
if (query.completed === "true") {
|
||||
conditions.push(eq(todos.isCompleted, true));
|
||||
} else if (query.completed === "false") {
|
||||
conditions.push(eq(todos.isCompleted, false));
|
||||
}
|
||||
|
||||
// Filter by category
|
||||
if (query.category) {
|
||||
conditions.push(eq(todos.category, query.category));
|
||||
}
|
||||
|
||||
const userTodos = await db
|
||||
.select()
|
||||
.from(todos)
|
||||
.where(and(...conditions))
|
||||
.orderBy(
|
||||
asc(todos.isCompleted),
|
||||
desc(sql`CASE ${todos.priority} WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 ELSE 3 END`),
|
||||
asc(todos.sortOrder),
|
||||
desc(todos.createdAt)
|
||||
);
|
||||
|
||||
return userTodos;
|
||||
}, {
|
||||
query: t.Object({
|
||||
completed: t.Optional(t.String()),
|
||||
category: t.Optional(t.String()),
|
||||
}),
|
||||
})
|
||||
|
||||
// GET categories (distinct)
|
||||
.get("/categories", async ({ request, headers }) => {
|
||||
const { userId } = await requireSessionOrBearer(request, headers);
|
||||
|
||||
const result = await db
|
||||
.selectDistinct({ category: todos.category })
|
||||
.from(todos)
|
||||
.where(and(eq(todos.userId, userId), sql`${todos.category} IS NOT NULL AND ${todos.category} != ''`))
|
||||
.orderBy(asc(todos.category));
|
||||
|
||||
return result.map((r) => r.category).filter(Boolean) as string[];
|
||||
})
|
||||
|
||||
// POST create todo
|
||||
.post("/", async ({ body, request, headers }) => {
|
||||
const { userId } = await requireSessionOrBearer(request, headers);
|
||||
|
||||
// Get max sort order
|
||||
const maxOrder = await db
|
||||
.select({ max: sql<number>`COALESCE(MAX(${todos.sortOrder}), 0)` })
|
||||
.from(todos)
|
||||
.where(eq(todos.userId, userId));
|
||||
|
||||
const [todo] = await db
|
||||
.insert(todos)
|
||||
.values({
|
||||
userId,
|
||||
title: body.title,
|
||||
description: body.description || null,
|
||||
priority: body.priority || "none",
|
||||
category: body.category || null,
|
||||
dueDate: body.dueDate ? new Date(body.dueDate) : null,
|
||||
sortOrder: (maxOrder[0]?.max ?? 0) + 1,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return todo;
|
||||
}, {
|
||||
body: t.Object({
|
||||
title: t.String({ minLength: 1 }),
|
||||
description: t.Optional(t.String()),
|
||||
priority: t.Optional(t.Union([
|
||||
t.Literal("high"),
|
||||
t.Literal("medium"),
|
||||
t.Literal("low"),
|
||||
t.Literal("none"),
|
||||
])),
|
||||
category: t.Optional(t.String()),
|
||||
dueDate: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
}),
|
||||
})
|
||||
|
||||
// PATCH update todo
|
||||
.patch("/:id", async ({ params, body, request, headers }) => {
|
||||
const { userId } = await requireSessionOrBearer(request, headers);
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(todos)
|
||||
.where(and(eq(todos.id, params.id), eq(todos.userId, userId)));
|
||||
|
||||
if (!existing.length) throw new Error("Not found");
|
||||
|
||||
const updates: Record<string, any> = { updatedAt: new Date() };
|
||||
if (body.title !== undefined) updates.title = body.title;
|
||||
if (body.description !== undefined) updates.description = body.description;
|
||||
if (body.priority !== undefined) updates.priority = body.priority;
|
||||
if (body.category !== undefined) updates.category = body.category || null;
|
||||
if (body.dueDate !== undefined) updates.dueDate = body.dueDate ? new Date(body.dueDate) : null;
|
||||
if (body.sortOrder !== undefined) updates.sortOrder = body.sortOrder;
|
||||
if (body.isCompleted !== undefined) {
|
||||
updates.isCompleted = body.isCompleted;
|
||||
updates.completedAt = body.isCompleted ? new Date() : null;
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(todos)
|
||||
.set(updates)
|
||||
.where(eq(todos.id, params.id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}, {
|
||||
params: t.Object({ id: t.String() }),
|
||||
body: t.Object({
|
||||
title: t.Optional(t.String()),
|
||||
description: t.Optional(t.String()),
|
||||
priority: t.Optional(t.Union([
|
||||
t.Literal("high"),
|
||||
t.Literal("medium"),
|
||||
t.Literal("low"),
|
||||
t.Literal("none"),
|
||||
])),
|
||||
category: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
dueDate: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
isCompleted: t.Optional(t.Boolean()),
|
||||
sortOrder: t.Optional(t.Number()),
|
||||
}),
|
||||
})
|
||||
|
||||
// PATCH toggle complete
|
||||
.patch("/:id/toggle", async ({ params, request, headers }) => {
|
||||
const { userId } = await requireSessionOrBearer(request, headers);
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(todos)
|
||||
.where(and(eq(todos.id, params.id), eq(todos.userId, userId)));
|
||||
|
||||
if (!existing.length) throw new Error("Not found");
|
||||
|
||||
const nowCompleted = !existing[0].isCompleted;
|
||||
const [updated] = await db
|
||||
.update(todos)
|
||||
.set({
|
||||
isCompleted: nowCompleted,
|
||||
completedAt: nowCompleted ? new Date() : null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(todos.id, params.id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}, {
|
||||
params: t.Object({ id: t.String() }),
|
||||
})
|
||||
|
||||
// DELETE todo
|
||||
.delete("/:id", async ({ params, request, headers }) => {
|
||||
const { userId } = await requireSessionOrBearer(request, headers);
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(todos)
|
||||
.where(and(eq(todos.id, params.id), eq(todos.userId, userId)));
|
||||
|
||||
if (!existing.length) throw new Error("Not found");
|
||||
|
||||
await db.delete(todos).where(eq(todos.id, params.id));
|
||||
return { success: true };
|
||||
}, {
|
||||
params: t.Object({ id: t.String() }),
|
||||
})
|
||||
|
||||
// POST bulk import (for migration)
|
||||
.post("/import", async ({ body, request, headers }) => {
|
||||
const { userId } = await requireSessionOrBearer(request, headers);
|
||||
|
||||
const imported = [];
|
||||
for (const item of body.todos) {
|
||||
const [todo] = await db
|
||||
.insert(todos)
|
||||
.values({
|
||||
userId,
|
||||
title: item.title,
|
||||
description: item.description || null,
|
||||
isCompleted: item.isCompleted || false,
|
||||
priority: item.priority || "none",
|
||||
category: item.category || null,
|
||||
dueDate: item.dueDate ? new Date(item.dueDate) : null,
|
||||
completedAt: item.completedAt ? new Date(item.completedAt) : null,
|
||||
sortOrder: item.sortOrder || 0,
|
||||
createdAt: item.createdAt ? new Date(item.createdAt) : new Date(),
|
||||
})
|
||||
.returning();
|
||||
imported.push(todo);
|
||||
}
|
||||
|
||||
return { imported: imported.length, todos: imported };
|
||||
}, {
|
||||
body: t.Object({
|
||||
todos: t.Array(t.Object({
|
||||
title: t.String(),
|
||||
description: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
isCompleted: t.Optional(t.Boolean()),
|
||||
priority: t.Optional(t.Union([
|
||||
t.Literal("high"),
|
||||
t.Literal("medium"),
|
||||
t.Literal("low"),
|
||||
t.Literal("none"),
|
||||
])),
|
||||
category: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
dueDate: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
completedAt: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
sortOrder: t.Optional(t.Number()),
|
||||
createdAt: t.Optional(t.String()),
|
||||
})),
|
||||
}),
|
||||
});
|
||||
101
backend/src/scripts/populate-summaries.ts
Normal file
101
backend/src/scripts/populate-summaries.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Populate daily_summaries from ~/clawd/memory/*.md files.
|
||||
* Usage: bun run src/scripts/populate-summaries.ts
|
||||
*/
|
||||
import { db } from "../db";
|
||||
import { dailySummaries } from "../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { readdir, readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
|
||||
const MEMORY_DIR = process.env.MEMORY_DIR || "/home/clawdbot/clawd/memory";
|
||||
|
||||
function extractHighlights(content: string): { text: string }[] {
|
||||
const highlights: { text: string }[] = [];
|
||||
const lines = content.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
// Match ## headings as key sections
|
||||
const h2Match = line.match(/^## (.+)/);
|
||||
if (h2Match) {
|
||||
highlights.push({ text: h2Match[1].trim() });
|
||||
}
|
||||
}
|
||||
|
||||
return highlights.slice(0, 20); // Cap at 20 highlights
|
||||
}
|
||||
|
||||
function extractStats(content: string): Record<string, number> {
|
||||
const lower = content.toLowerCase();
|
||||
const stats: Record<string, number> = {};
|
||||
|
||||
// Count deploy mentions
|
||||
const deployMatches = lower.match(/\b(deploy|deployed|deployment|redeployed)\b/g);
|
||||
if (deployMatches) stats.deploys = deployMatches.length;
|
||||
|
||||
// Count commit/push mentions
|
||||
const commitMatches = lower.match(/\b(commit|committed|pushed|push)\b/g);
|
||||
if (commitMatches) stats.commits = commitMatches.length;
|
||||
|
||||
// Count task mentions
|
||||
const taskMatches = lower.match(/\b(completed|task completed|hq-\d+.*completed)\b/g);
|
||||
if (taskMatches) stats.tasksCompleted = taskMatches.length;
|
||||
|
||||
// Count feature mentions
|
||||
const featureMatches = lower.match(/\b(feature|built|implemented|added|created)\b/g);
|
||||
if (featureMatches) stats.featuresBuilt = Math.min(featureMatches.length, 30);
|
||||
|
||||
// Count fix mentions
|
||||
const fixMatches = lower.match(/\b(fix|fixed|bug|bugfix|hotfix)\b/g);
|
||||
if (fixMatches) stats.bugsFixed = fixMatches.length;
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Reading memory files from ${MEMORY_DIR}...`);
|
||||
|
||||
const files = await readdir(MEMORY_DIR);
|
||||
const mdFiles = files
|
||||
.filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f))
|
||||
.sort();
|
||||
|
||||
console.log(`Found ${mdFiles.length} memory files`);
|
||||
|
||||
for (const file of mdFiles) {
|
||||
const date = file.replace(".md", "");
|
||||
const filePath = join(MEMORY_DIR, file);
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
|
||||
const highlights = extractHighlights(content);
|
||||
const stats = extractStats(content);
|
||||
|
||||
// Upsert
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(dailySummaries)
|
||||
.where(eq(dailySummaries.date, date))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(dailySummaries)
|
||||
.set({ content, highlights, stats, updatedAt: new Date() })
|
||||
.where(eq(dailySummaries.date, date));
|
||||
console.log(`Updated: ${date}`);
|
||||
} else {
|
||||
await db
|
||||
.insert(dailySummaries)
|
||||
.values({ date, content, highlights, stats });
|
||||
console.log(`Inserted: ${date}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Done!");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("Failed:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
476
backend/src/seed-security.ts
Normal file
476
backend/src/seed-security.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
import { db } from "./db";
|
||||
import { securityAudits, type SecurityFinding } from "./db/schema";
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
function finding(
|
||||
status: SecurityFinding["status"],
|
||||
title: string,
|
||||
description: string,
|
||||
recommendation: string
|
||||
): SecurityFinding {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
status,
|
||||
title,
|
||||
description,
|
||||
recommendation,
|
||||
};
|
||||
}
|
||||
|
||||
const auditData: {
|
||||
projectName: string;
|
||||
category: string;
|
||||
score: number;
|
||||
findings: SecurityFinding[];
|
||||
}[] = [
|
||||
// ═══════════════════════════════════════════
|
||||
// HAMMER DASHBOARD
|
||||
// ═══════════════════════════════════════════
|
||||
{
|
||||
projectName: "Hammer Dashboard",
|
||||
category: "Authentication",
|
||||
score: 75,
|
||||
findings: [
|
||||
finding("strong", "BetterAuth with email/password", "Uses BetterAuth library with email+password authentication, secure session management with cookie-based sessions.", ""),
|
||||
finding("strong", "CSRF protection enabled", "BetterAuth CSRF check is explicitly enabled (disableCSRFCheck: false).", ""),
|
||||
finding("strong", "Cross-subdomain cookies", "Secure cookie configuration with domain scoping to .donovankelly.xyz.", ""),
|
||||
finding("needs_improvement", "No MFA support", "Multi-factor authentication is not implemented. Single-factor auth only.", "Add TOTP or WebAuthn MFA support via BetterAuth plugins."),
|
||||
finding("needs_improvement", "No password policy enforcement", "No minimum password length, complexity, or breach-check enforcement visible in auth config.", "Configure BetterAuth password policy with minimum length (12+), complexity requirements."),
|
||||
finding("needs_improvement", "Open signup", "emailAndPassword.enabled is true without disableSignUp — anyone can register.", "Set disableSignUp: true and use invite-only registration."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Hammer Dashboard",
|
||||
category: "Authorization",
|
||||
score: 70,
|
||||
findings: [
|
||||
finding("strong", "Role-based access control", "Users have roles (admin/user). Admin-only routes check role before processing.", ""),
|
||||
finding("strong", "Bearer token + session dual auth", "API supports both session cookies and bearer token auth for programmatic access.", ""),
|
||||
finding("needs_improvement", "Bearer token is env-based static token", "API_BEARER_TOKEN is a single static token shared across all API consumers. No per-client tokens.", "Implement per-client API keys or use OAuth2 client credentials."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Hammer Dashboard",
|
||||
category: "Data Protection",
|
||||
score: 80,
|
||||
findings: [
|
||||
finding("strong", "TLS encryption in transit", "All traffic served over HTTPS via Let's Encrypt certificates (auto-renewed). Valid cert for dash.donovankelly.xyz.", ""),
|
||||
finding("strong", "Database in Docker network", "PostgreSQL is not exposed to the public internet — only accessible within the Docker compose network.", ""),
|
||||
finding("needs_improvement", "No database encryption at rest", "PostgreSQL data volume uses default filesystem — no disk-level or column-level encryption.", "Consider enabling LUKS for the Docker volume or use PostgreSQL pgcrypto for sensitive columns."),
|
||||
finding("needs_improvement", "No backup strategy visible", "No automated database backup configuration found in the compose file.", "Add pg_dump cron backup to S3 or another offsite location."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Hammer Dashboard",
|
||||
category: "Infrastructure",
|
||||
score: 70,
|
||||
findings: [
|
||||
finding("strong", "Container isolation", "Backend and database run as separate Docker containers with defined networking.", ""),
|
||||
finding("strong", "Dokploy managed deployment", "Deployed via Dokploy with compose — automated builds and deploys on git push.", ""),
|
||||
finding("needs_improvement", "Database uses simple credentials", "POSTGRES_USER/PASSWORD are set via env vars in docker-compose but appear to be simple values.", "Use strong randomly generated passwords stored in a secrets manager."),
|
||||
finding("needs_improvement", "No health check monitoring", "Health endpoint exists (/health) but no external monitoring or alerting configured.", "Set up uptime monitoring (e.g., UptimeRobot, Betterstack) with alerts."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Hammer Dashboard",
|
||||
category: "Application Security",
|
||||
score: 65,
|
||||
findings: [
|
||||
finding("strong", "Elysia type validation on routes", "Most routes use Elysia's t.Object() schema validation for request bodies — provides automatic input validation.", ""),
|
||||
finding("strong", "SQL injection protection via Drizzle ORM", "All database queries use Drizzle ORM's parameterized query builder — no raw SQL string concatenation.", ""),
|
||||
finding("strong", "Generic error responses", "Error handler returns 'Internal server error' for unhandled errors without stack traces.", ""),
|
||||
finding("needs_improvement", "No rate limiting", "No rate limiting middleware found on any routes. API is vulnerable to brute-force and abuse.", "Add rate limiting middleware (e.g., per-IP request limits on auth endpoints)."),
|
||||
finding("needs_improvement", "CORS allows localhost", "CORS origin includes http://localhost:5173 in production, which is unnecessary.", "Remove localhost from CORS origins in production builds."),
|
||||
finding("needs_improvement", "Some routes lack body validation", "Activity and chat routes have 0 type validations — accepting unvalidated input.", "Add t.Object() body/param validation to all routes."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Hammer Dashboard",
|
||||
category: "Dependency Security",
|
||||
score: 60,
|
||||
findings: [
|
||||
finding("needs_improvement", "Limited dependency set (good)", "Backend has only 5 production dependencies — small attack surface.", ""),
|
||||
finding("needs_improvement", "No automated vulnerability scanning", "No bun audit or Snyk integration found. Dependencies not regularly checked for CVEs.", "Add dependency scanning to CI pipeline or run periodic audits."),
|
||||
finding("needs_improvement", "Some dependencies slightly outdated", "Elysia 1.2.25 and drizzle-orm 0.44.2 — newer versions available with security fixes.", "Update to latest stable versions: elysia 1.4.x, drizzle-orm 0.45.x."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Hammer Dashboard",
|
||||
category: "Logging & Monitoring",
|
||||
score: 40,
|
||||
findings: [
|
||||
finding("needs_improvement", "Console-only logging", "Errors logged via console.error — no structured logging, no log aggregation.", "Use a structured logger (pino/winston) and ship logs to a central service."),
|
||||
finding("critical", "No audit trail", "No logging of who accessed what, auth events, or data changes.", "Implement audit logging for auth events, CRUD operations, and admin actions."),
|
||||
finding("critical", "No alerting", "No alerting on errors, failed auth attempts, or unusual activity.", "Set up alerting via email/Slack for critical errors and security events."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Hammer Dashboard",
|
||||
category: "Compliance",
|
||||
score: 45,
|
||||
findings: [
|
||||
finding("needs_improvement", "No data retention policy", "No defined policy for how long user data, tasks, or sessions are retained.", "Define and implement data retention policies — auto-expire old sessions, archive completed tasks."),
|
||||
finding("needs_improvement", "No privacy policy", "No privacy policy or terms of service for users.", "Create a basic privacy policy documenting data collection and usage."),
|
||||
finding("needs_improvement", "Session data lacks expiry cleanup", "Old sessions may accumulate in the database without cleanup.", "Add a periodic job to clean up expired sessions."),
|
||||
],
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// NETWORK APP
|
||||
// ═══════════════════════════════════════════
|
||||
{
|
||||
projectName: "Network App",
|
||||
category: "Authentication",
|
||||
score: 85,
|
||||
findings: [
|
||||
finding("strong", "BetterAuth with bearer plugin", "Uses BetterAuth with email/password and bearer token plugin for mobile app support.", ""),
|
||||
finding("strong", "Invite-only registration", "Open signup is disabled (disableSignUp: true). Registration requires invite link.", ""),
|
||||
finding("strong", "Session configuration", "7-day session expiry with daily refresh. Cookie caching enabled for 5 min. Cross-subdomain cookies with secure+sameSite:none.", ""),
|
||||
finding("strong", "Signup endpoint blocked", "POST /api/auth/sign-up/email explicitly returns 403 — defense in depth on top of disableSignUp.", ""),
|
||||
finding("needs_improvement", "No MFA", "No multi-factor authentication implemented.", "Add TOTP MFA via BetterAuth plugin."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Network App",
|
||||
category: "Authorization",
|
||||
score: 80,
|
||||
findings: [
|
||||
finding("strong", "Auth middleware plugin", "Dedicated authMiddleware Elysia plugin that derives user from session — consistently applied across routes.", ""),
|
||||
finding("strong", "Admin separation", "Admin routes check user.role === 'admin' with dedicated admin routes.", ""),
|
||||
finding("strong", "Scoped auth derivation", "Auth middleware uses 'as: scoped' derivation for proper Elysia context isolation.", ""),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Network App",
|
||||
category: "Application Security",
|
||||
score: 80,
|
||||
findings: [
|
||||
finding("strong", "Rate limiting implemented", "Custom rate limiting middleware with per-IP buckets. Different limits for auth, general API, and sensitive routes.", ""),
|
||||
finding("strong", "Extensive input validation", "Most routes (34+ files) use Elysia t.Object() type validation. High validation coverage.", ""),
|
||||
finding("strong", "Drizzle ORM for SQL safety", "All queries via Drizzle ORM — parameterized, no raw SQL injection risk.", ""),
|
||||
finding("needs_improvement", "Stack traces in error responses", "Error handler logs full stack traces and sends them in responses (line 100: stack) — even in production.", "Only include stack traces in development. Check NODE_ENV before including."),
|
||||
finding("needs_improvement", "CORS allows any origin in fallback", "Falls back to localhost:3000 if ALLOWED_ORIGINS env not set — risky if env is misconfigured.", "Set a strict default origin list rather than localhost."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Network App",
|
||||
category: "Data Protection",
|
||||
score: 75,
|
||||
findings: [
|
||||
finding("strong", "TLS via Let's Encrypt", "Valid Let's Encrypt certificate for app.thenetwork.donovankelly.xyz. Auto-renewed.", ""),
|
||||
finding("strong", "PII handling awareness", "Network app handles contacts and personal data — uses dedicated client/interaction models.", ""),
|
||||
finding("needs_improvement", "No encryption at rest", "Contact data (names, emails, phone numbers) stored as plain text in PostgreSQL.", "Consider column-level encryption for PII fields."),
|
||||
finding("needs_improvement", "Email service (Resend) integration", "Uses Resend for sending emails — API key stored in env vars.", "Ensure Resend API key is rotated periodically."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Network App",
|
||||
category: "Dependency Security",
|
||||
score: 55,
|
||||
findings: [
|
||||
finding("needs_improvement", "LangChain AI dependencies", "Includes @langchain/anthropic, @langchain/core, @langchain/openai — large dependency trees with potential supply chain risk.", "Audit LangChain dependencies regularly. Consider pinning exact versions."),
|
||||
finding("needs_improvement", "No automated scanning", "No CI/CD vulnerability scanning configured.", "Add bun audit or Snyk scanning to the pipeline."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Network App",
|
||||
category: "Logging & Monitoring",
|
||||
score: 50,
|
||||
findings: [
|
||||
finding("strong", "Audit log routes", "Has dedicated audit-logs route — suggests audit logging infrastructure exists.", ""),
|
||||
finding("needs_improvement", "Console-based logging", "Error logging via console.error only. No structured log format.", "Implement structured logging with request IDs and ship to log aggregation."),
|
||||
finding("needs_improvement", "No external monitoring", "No uptime monitoring or alerting configured.", "Set up uptime monitoring and error alerting."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Network App",
|
||||
category: "Infrastructure",
|
||||
score: 75,
|
||||
findings: [
|
||||
finding("strong", "Docker container isolation", "Runs in isolated Docker containers on Dokploy.", ""),
|
||||
finding("strong", "Production Dockerfile", "Multi-stage Dockerfile with production dependencies only (--production flag). NODE_ENV=production set.", ""),
|
||||
finding("strong", "Entrypoint script", "Uses entrypoint.sh for startup — allows db:push before start.", ""),
|
||||
finding("needs_improvement", "No container health checks", "Dockerfile doesn't define HEALTHCHECK instruction.", "Add Docker HEALTHCHECK to enable container auto-restart on failure."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Network App",
|
||||
category: "Compliance",
|
||||
score: 40,
|
||||
findings: [
|
||||
finding("needs_improvement", "Handles personal contacts", "Stores names, emails, phone numbers, notes about individuals — GDPR-relevant data.", "Implement data export and deletion capabilities for GDPR compliance."),
|
||||
finding("needs_improvement", "No data retention policy", "No automatic cleanup of old data.", "Define retention periods for contacts and interactions."),
|
||||
finding("critical", "No consent management", "No mechanism to track consent for storing contact information.", "Add consent tracking for contact data collection."),
|
||||
],
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// TODO APP
|
||||
// ═══════════════════════════════════════════
|
||||
{
|
||||
projectName: "Todo App",
|
||||
category: "Authentication",
|
||||
score: 80,
|
||||
findings: [
|
||||
finding("strong", "BetterAuth with invite system", "Uses BetterAuth for auth. Has a dedicated invite system with expiring tokens.", ""),
|
||||
finding("strong", "Bearer token support", "Supports bearer tokens via @elysiajs/bearer for API access.", ""),
|
||||
finding("strong", "Invite token validation", "Invite tokens are validated for expiry and status before acceptance.", ""),
|
||||
finding("needs_improvement", "No MFA", "No multi-factor authentication available.", "Add TOTP MFA support."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Todo App",
|
||||
category: "Authorization",
|
||||
score: 75,
|
||||
findings: [
|
||||
finding("strong", "Hammer API key auth", "Separate authentication for the Hammer bot API using dedicated API key.", ""),
|
||||
finding("strong", "Admin routes", "Admin routes with role checking for user management.", ""),
|
||||
finding("needs_improvement", "Shared auth patterns", "Auth middleware duplicated across route files rather than centralized.", "Centralize auth middleware into a shared plugin like Network App does."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Todo App",
|
||||
category: "Application Security",
|
||||
score: 65,
|
||||
findings: [
|
||||
finding("strong", "Input validation", "Routes use Elysia type validation (t.Object) — high coverage across routes.", ""),
|
||||
finding("strong", "Drizzle ORM", "Parameterized queries via Drizzle ORM prevent SQL injection.", ""),
|
||||
finding("needs_improvement", "Stack traces conditional but present", "Error handler includes stack in non-production mode (NODE_ENV check). But stack is still captured.", "Ensure NODE_ENV=production is always set in deployed containers."),
|
||||
finding("needs_improvement", "No rate limiting", "No rate limiting middleware found. Endpoints vulnerable to abuse.", "Implement rate limiting similar to Network App's approach."),
|
||||
finding("needs_improvement", "CORS allows localhost fallback", "Falls back to localhost:5173 and todo.donovankelly.xyz if ALLOWED_ORIGINS not set.", "Remove localhost from production CORS."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Todo App",
|
||||
category: "Data Protection",
|
||||
score: 75,
|
||||
findings: [
|
||||
finding("strong", "TLS certificates valid", "Valid Let's Encrypt certs for api.todo.donovankelly.xyz and app.todo.donovankelly.xyz.", ""),
|
||||
finding("strong", "Minimal PII", "Todo app stores minimal personal data — mainly task content.", ""),
|
||||
finding("needs_improvement", "No backup automation", "No backup strategy visible in compose configuration.", "Add automated database backups."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Todo App",
|
||||
category: "Dependency Security",
|
||||
score: 55,
|
||||
findings: [
|
||||
finding("needs_improvement", "pg-boss dependency", "Uses pg-boss for job queuing — adds complexity and dependency surface.", "Ensure pg-boss is kept updated."),
|
||||
finding("needs_improvement", "Zod v4 (beta)", "Using zod 4.3.6 which is a relatively new major version.", "Monitor for security advisories on the new Zod version."),
|
||||
finding("needs_improvement", "No vulnerability scanning", "No automated dependency scanning in CI.", "Add scanning to CI pipeline."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Todo App",
|
||||
category: "Infrastructure",
|
||||
score: 70,
|
||||
findings: [
|
||||
finding("strong", "Docker compose with Dokploy", "Proper compose setup with db:push in CMD for schema management.", ""),
|
||||
finding("strong", "Separate API and frontend", "API and frontend deployed as separate services.", ""),
|
||||
finding("needs_improvement", "No health check monitoring", "No external monitoring configured.", "Add uptime monitoring."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Todo App",
|
||||
category: "Logging & Monitoring",
|
||||
score: 35,
|
||||
findings: [
|
||||
finding("needs_improvement", "Console logging only", "Errors logged to stdout/stderr via console. No structured logging.", "Implement structured logging."),
|
||||
finding("critical", "No audit logging", "No tracking of user actions, auth events, or data changes.", "Add audit logging for all CRUD operations."),
|
||||
finding("critical", "No alerting system", "No error alerting or uptime monitoring.", "Configure alerts for errors and downtime."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Todo App",
|
||||
category: "Compliance",
|
||||
score: 50,
|
||||
findings: [
|
||||
finding("needs_improvement", "No data retention policy", "No cleanup of completed tasks or expired sessions.", "Define data retention periods."),
|
||||
finding("needs_improvement", "No privacy documentation", "No privacy policy for users.", "Create privacy policy."),
|
||||
],
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// NKODE
|
||||
// ═══════════════════════════════════════════
|
||||
{
|
||||
projectName: "nKode",
|
||||
category: "Authentication",
|
||||
score: 90,
|
||||
findings: [
|
||||
finding("strong", "OPAQUE protocol (state-of-the-art)", "Uses OPAQUE (opaque-ke v4) for password authentication — server never sees plaintext passwords. This is cryptographically stronger than traditional hashing.", ""),
|
||||
finding("strong", "OIDC implementation", "Full OpenID Connect flow with discovery endpoint, JWKs, token endpoint, and userinfo.", ""),
|
||||
finding("strong", "Argon2 password hashing", "OPAQUE configured with Argon2 (opaque-ke features=['argon2']) — the recommended modern password hashing algorithm.", ""),
|
||||
finding("needs_improvement", "No MFA layer", "OPAQUE provides strong password auth but no second factor.", "Consider adding FIDO2/WebAuthn as a second factor."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "nKode",
|
||||
category: "Authorization",
|
||||
score: 75,
|
||||
findings: [
|
||||
finding("strong", "Token-based authorization", "Uses OIDC tokens (JWT) for API authorization after OPAQUE login.", ""),
|
||||
finding("strong", "Protected route extractors", "Has dedicated extractors.rs for auth extraction — consistent route protection.", ""),
|
||||
finding("needs_improvement", "Role system unclear", "No visible role-based access control in the API routes.", "Implement RBAC if different user permission levels are needed."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "nKode",
|
||||
category: "Application Security",
|
||||
score: 75,
|
||||
findings: [
|
||||
finding("strong", "Rust memory safety", "Backend written in Rust — eliminates entire classes of vulnerabilities (buffer overflows, use-after-free, data races).", ""),
|
||||
finding("strong", "Type-safe with serde", "All request/response types defined with serde — strong input validation at deserialization.", ""),
|
||||
finding("strong", "Axum framework", "Uses Axum (Tokio-based) — well-maintained with good security practices.", ""),
|
||||
finding("needs_improvement", "CORS includes localhost", "CORS origins include localhost:3000 and localhost:5173 in production code.", "Move development origins to environment-only configuration."),
|
||||
finding("needs_improvement", "No rate limiting visible", "No rate limiting middleware found in the Axum router setup.", "Add tower-governor or similar rate limiting middleware."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "nKode",
|
||||
category: "Data Protection",
|
||||
score: 80,
|
||||
findings: [
|
||||
finding("strong", "TLS on all endpoints", "Valid Let's Encrypt certs for app.nkode.donovankelly.xyz and api.nkode.donovankelly.xyz.", ""),
|
||||
finding("strong", "OPAQUE prevents password exposure", "Server stores OPAQUE registration records, not password hashes — even a database breach doesn't expose passwords.", ""),
|
||||
finding("needs_improvement", "Login data storage", "Stores user login data (credentials manager data) — may contain sensitive information.", "Ensure login data is encrypted at the application level."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "nKode",
|
||||
category: "Dependency Security",
|
||||
score: 75,
|
||||
findings: [
|
||||
finding("strong", "Rust ecosystem (cargo audit)", "Rust has cargo-audit for vulnerability checking. Smaller dependency trees than Node.js.", ""),
|
||||
finding("strong", "Pinned versions", "Cargo.toml uses specific version ranges — reproducible builds.", ""),
|
||||
finding("needs_improvement", "No CI audit pipeline", "No automated cargo-audit in CI.", "Add cargo-audit to CI pipeline."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "nKode",
|
||||
category: "Infrastructure",
|
||||
score: 70,
|
||||
findings: [
|
||||
finding("strong", "Docker containerized", "Deployed as Docker container on Dokploy.", ""),
|
||||
finding("strong", "Minimal base image", "Rust binary — small attack surface compared to Node.js containers.", ""),
|
||||
finding("needs_improvement", "Service architecture spread", "Multiple microservices (OIDC, core, services) — increases operational complexity.", "Document service architecture and inter-service auth."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "nKode",
|
||||
category: "Logging & Monitoring",
|
||||
score: 55,
|
||||
findings: [
|
||||
finding("strong", "tracing/tracing-subscriber", "Uses Rust tracing ecosystem — structured, span-based logging.", ""),
|
||||
finding("needs_improvement", "Stdout only", "Logs go to stdout only. No log aggregation configured.", "Ship tracing output to a log aggregation service."),
|
||||
finding("needs_improvement", "No alerting", "No alerting or monitoring configured.", "Set up monitoring and alerting."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "nKode",
|
||||
category: "Compliance",
|
||||
score: 50,
|
||||
findings: [
|
||||
finding("needs_improvement", "Password manager data", "Stores user password/login data — high sensitivity. No documented data handling policy.", "Document data classification and handling procedures for stored credentials."),
|
||||
finding("needs_improvement", "No data export/deletion", "No user data export or deletion capability visible.", "Implement GDPR-style data portability and right-to-erasure."),
|
||||
],
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// INFRASTRUCTURE
|
||||
// ═══════════════════════════════════════════
|
||||
{
|
||||
projectName: "Infrastructure",
|
||||
category: "Authentication",
|
||||
score: 65,
|
||||
findings: [
|
||||
finding("strong", "SSH key authentication available", "VPS supports SSH key authentication.", ""),
|
||||
finding("strong", "Gitea self-hosted", "Self-hosted Gitea with authenticated access — no reliance on third-party code hosting.", ""),
|
||||
finding("needs_improvement", "Git credentials in URL", "Git remote URLs contain credentials encoded in the URL string.", "Use SSH keys or git credential helper instead of URL-embedded credentials."),
|
||||
finding("needs_improvement", "Dokploy API token", "Single API token for Dokploy management. Token compromise gives full deploy control.", "Rotate Dokploy API token periodically. Consider IP-restricted access."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Infrastructure",
|
||||
category: "Data Protection",
|
||||
score: 70,
|
||||
findings: [
|
||||
finding("strong", "TLS everywhere", "All 7 domains verified with valid Let's Encrypt certificates. Auto-renewal via Dokploy/Traefik.", ""),
|
||||
finding("strong", "Separate VPS isolation", "Clawdbot VPS (72.60.68.214) and Dokploy VPS (191.101.0.153) are separate machines — blast radius limited.", ""),
|
||||
finding("needs_improvement", "No centralized backup", "No unified backup strategy across all services.", "Implement automated backups for all databases to offsite storage."),
|
||||
finding("needs_improvement", "DNS at Hostinger", "DNS managed through Hostinger control panel. No DNSSEC enabled.", "Consider enabling DNSSEC for donovankelly.xyz."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Infrastructure",
|
||||
category: "Infrastructure",
|
||||
score: 60,
|
||||
findings: [
|
||||
finding("strong", "Dokploy orchestration", "Dokploy manages container deployments with automated builds — reduces manual config drift.", ""),
|
||||
finding("strong", "Let's Encrypt auto-renewal", "TLS certificates automatically renewed — no manual intervention needed.", ""),
|
||||
finding("needs_improvement", "No firewall documentation", "Firewall rules for both VPS machines not documented or audited.", "Document and audit iptables/ufw rules on both VPS instances."),
|
||||
finding("needs_improvement", "SSH configuration unknown", "SSH hardening status (password auth disabled, key-only, non-default port) not verified.", "Audit SSH config: disable password auth, use key-only, change default port."),
|
||||
finding("needs_improvement", "No container vulnerability scanning", "Docker images not scanned for vulnerabilities before deployment.", "Add Trivy or similar container scanning to the deployment pipeline."),
|
||||
finding("critical", "Exposed ports unknown", "No audit of which ports are exposed on both VPS machines.", "Run port scan audit on both VPS IPs and close unnecessary ports."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Infrastructure",
|
||||
category: "Logging & Monitoring",
|
||||
score: 30,
|
||||
findings: [
|
||||
finding("critical", "No centralized logging", "No log aggregation across services. Each container logs independently to stdout.", "Deploy a log aggregation stack (e.g., Loki + Grafana, or Betterstack)."),
|
||||
finding("critical", "No uptime monitoring", "No external uptime monitoring for any of the 7+ domains.", "Set up UptimeRobot or Betterstack for all production domains."),
|
||||
finding("critical", "No intrusion detection", "No IDS/IPS on either VPS. No fail2ban or similar tools verified.", "Install and configure fail2ban on both VPS instances."),
|
||||
finding("needs_improvement", "No system update policy", "No documented policy for OS and package updates.", "Set up unattended-upgrades for security patches on both VPS machines."),
|
||||
],
|
||||
},
|
||||
{
|
||||
projectName: "Infrastructure",
|
||||
category: "Compliance",
|
||||
score: 40,
|
||||
findings: [
|
||||
finding("needs_improvement", "No incident response plan", "No documented procedure for security incidents.", "Create an incident response runbook."),
|
||||
finding("needs_improvement", "No access review", "No periodic review of who has access to VPS, Dokploy, Gitea.", "Conduct quarterly access reviews."),
|
||||
finding("needs_improvement", "No secrets rotation policy", "API tokens, database passwords, and auth secrets have no rotation schedule.", "Implement a secrets rotation policy (quarterly minimum)."),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
async function seedSecurityAudits() {
|
||||
console.log("🛡️ Seeding security audit data...");
|
||||
|
||||
// Clear existing data
|
||||
await db.delete(securityAudits);
|
||||
console.log(" Cleared existing audit data");
|
||||
|
||||
// Insert all audit data
|
||||
for (const audit of auditData) {
|
||||
await db.insert(securityAudits).values({
|
||||
projectName: audit.projectName,
|
||||
category: audit.category,
|
||||
score: audit.score,
|
||||
findings: audit.findings,
|
||||
lastAudited: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` ✅ Inserted ${auditData.length} audit entries across ${new Set(auditData.map(a => a.projectName)).size} projects`);
|
||||
|
||||
// Print summary
|
||||
const projects = [...new Set(auditData.map(a => a.projectName))];
|
||||
for (const project of projects) {
|
||||
const projectAudits = auditData.filter(a => a.projectName === project);
|
||||
const avgScore = Math.round(projectAudits.reduce((sum, a) => sum + a.score, 0) / projectAudits.length);
|
||||
const totalFindings = projectAudits.reduce((sum, a) => sum + a.findings.length, 0);
|
||||
console.log(` ${project}: avg score ${avgScore}/100, ${totalFindings} findings`);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
seedSecurityAudits().catch((err) => {
|
||||
console.error("Failed to seed security data:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -9,7 +9,9 @@
|
||||
"better-auth": "^1.4.17",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwindcss": "^4.1.18",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -259,16 +261,28 @@
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
|
||||
|
||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/node": ["@types/node@24.10.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.54.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/type-utils": "8.54.0", "@typescript-eslint/utils": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.54.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.54.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA=="],
|
||||
@@ -289,6 +303,8 @@
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
@@ -301,6 +317,8 @@
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
|
||||
@@ -317,12 +335,24 @@
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001766", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||
|
||||
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
|
||||
|
||||
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
|
||||
|
||||
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
@@ -335,12 +365,18 @@
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.279", "", {}, "sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
|
||||
@@ -369,8 +405,12 @@
|
||||
|
||||
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
@@ -399,20 +439,38 @@
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
|
||||
|
||||
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
|
||||
|
||||
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
|
||||
|
||||
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||
|
||||
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||
|
||||
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
|
||||
|
||||
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
|
||||
|
||||
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
@@ -467,10 +525,100 @@
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||
|
||||
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
|
||||
|
||||
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
|
||||
|
||||
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
|
||||
|
||||
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
|
||||
|
||||
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
|
||||
|
||||
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
|
||||
|
||||
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
|
||||
|
||||
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
|
||||
|
||||
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
|
||||
|
||||
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
|
||||
|
||||
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
|
||||
|
||||
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
|
||||
|
||||
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
|
||||
|
||||
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
|
||||
|
||||
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
|
||||
|
||||
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||
|
||||
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||
|
||||
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
|
||||
|
||||
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
|
||||
|
||||
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
|
||||
|
||||
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
|
||||
|
||||
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
|
||||
|
||||
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
|
||||
|
||||
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
|
||||
|
||||
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
|
||||
|
||||
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
|
||||
|
||||
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
|
||||
|
||||
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
|
||||
|
||||
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
|
||||
|
||||
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||
|
||||
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
|
||||
|
||||
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
|
||||
|
||||
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
|
||||
|
||||
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
|
||||
|
||||
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
|
||||
|
||||
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||
|
||||
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
|
||||
|
||||
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
|
||||
|
||||
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
|
||||
|
||||
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
|
||||
|
||||
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
|
||||
|
||||
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
|
||||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
@@ -491,6 +639,8 @@
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
@@ -503,18 +653,30 @@
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||
|
||||
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
||||
|
||||
"react-router": ["react-router@7.13.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw=="],
|
||||
|
||||
"react-router-dom": ["react-router-dom@7.13.0", "", { "dependencies": { "react-router": "7.13.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g=="],
|
||||
|
||||
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
|
||||
|
||||
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
|
||||
|
||||
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
|
||||
|
||||
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"rollup": ["rollup@4.57.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.0", "@rollup/rollup-android-arm64": "4.57.0", "@rollup/rollup-darwin-arm64": "4.57.0", "@rollup/rollup-darwin-x64": "4.57.0", "@rollup/rollup-freebsd-arm64": "4.57.0", "@rollup/rollup-freebsd-x64": "4.57.0", "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", "@rollup/rollup-linux-arm-musleabihf": "4.57.0", "@rollup/rollup-linux-arm64-gnu": "4.57.0", "@rollup/rollup-linux-arm64-musl": "4.57.0", "@rollup/rollup-linux-loong64-gnu": "4.57.0", "@rollup/rollup-linux-loong64-musl": "4.57.0", "@rollup/rollup-linux-ppc64-gnu": "4.57.0", "@rollup/rollup-linux-ppc64-musl": "4.57.0", "@rollup/rollup-linux-riscv64-gnu": "4.57.0", "@rollup/rollup-linux-riscv64-musl": "4.57.0", "@rollup/rollup-linux-s390x-gnu": "4.57.0", "@rollup/rollup-linux-x64-gnu": "4.57.0", "@rollup/rollup-linux-x64-musl": "4.57.0", "@rollup/rollup-openbsd-x64": "4.57.0", "@rollup/rollup-openharmony-arm64": "4.57.0", "@rollup/rollup-win32-arm64-msvc": "4.57.0", "@rollup/rollup-win32-ia32-msvc": "4.57.0", "@rollup/rollup-win32-x64-gnu": "4.57.0", "@rollup/rollup-win32-x64-msvc": "4.57.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA=="],
|
||||
@@ -533,8 +695,16 @@
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
|
||||
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
|
||||
|
||||
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||
@@ -543,6 +713,10 @@
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
@@ -553,10 +727,26 @@
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||
|
||||
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
|
||||
|
||||
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
||||
|
||||
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
@@ -571,6 +761,8 @@
|
||||
|
||||
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||
@@ -593,6 +785,10 @@
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ import { useSession } from "./lib/auth-client";
|
||||
// Lazy-loaded pages for code splitting
|
||||
const DashboardPage = lazy(() => import("./pages/DashboardPage").then(m => ({ default: m.DashboardPage })));
|
||||
const QueuePage = lazy(() => import("./pages/QueuePage").then(m => ({ default: m.QueuePage })));
|
||||
const ChatPage = lazy(() => import("./pages/ChatPage").then(m => ({ default: m.ChatPage })));
|
||||
const ProjectsPage = lazy(() => import("./pages/ProjectsPage").then(m => ({ default: m.ProjectsPage })));
|
||||
const TaskPage = lazy(() => import("./pages/TaskPage").then(m => ({ default: m.TaskPage })));
|
||||
const ActivityPage = lazy(() => import("./pages/ActivityPage").then(m => ({ default: m.ActivityPage })));
|
||||
const SummariesPage = lazy(() => import("./pages/SummariesPage").then(m => ({ default: m.SummariesPage })));
|
||||
const AdminPage = lazy(() => import("./components/AdminPage").then(m => ({ default: m.AdminPage })));
|
||||
const SecurityPage = lazy(() => import("./pages/SecurityPage").then(m => ({ default: m.SecurityPage })));
|
||||
const TodosPage = lazy(() => import("./pages/TodosPage").then(m => ({ default: m.TodosPage })));
|
||||
|
||||
function PageLoader() {
|
||||
return (
|
||||
@@ -36,8 +38,10 @@ function AuthenticatedApp() {
|
||||
<Route path="/queue" element={<Suspense fallback={<PageLoader />}><QueuePage /></Suspense>} />
|
||||
<Route path="/task/:taskRef" element={<Suspense fallback={<PageLoader />}><TaskPage /></Suspense>} />
|
||||
<Route path="/projects" element={<Suspense fallback={<PageLoader />}><ProjectsPage /></Suspense>} />
|
||||
<Route path="/chat" element={<Suspense fallback={<PageLoader />}><ChatPage /></Suspense>} />
|
||||
<Route path="/activity" element={<Suspense fallback={<PageLoader />}><ActivityPage /></Suspense>} />
|
||||
<Route path="/summaries" element={<Suspense fallback={<PageLoader />}><SummariesPage /></Suspense>} />
|
||||
<Route path="/security" element={<Suspense fallback={<PageLoader />}><SecurityPage /></Suspense>} />
|
||||
<Route path="/todos" element={<Suspense fallback={<PageLoader />}><TodosPage /></Suspense>} />
|
||||
<Route path="/admin" element={<Suspense fallback={<PageLoader />}><AdminPage /></Suspense>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
|
||||
@@ -10,9 +10,11 @@ import { signOut } from "../lib/auth-client";
|
||||
const navItems = [
|
||||
{ to: "/", label: "Dashboard", icon: "🔨", badgeKey: null },
|
||||
{ to: "/queue", label: "Queue", icon: "📋", badgeKey: "queue" },
|
||||
{ to: "/todos", label: "Todos", icon: "✅", badgeKey: null },
|
||||
{ to: "/projects", label: "Projects", icon: "📁", badgeKey: null },
|
||||
{ to: "/activity", label: "Activity", icon: "📝", badgeKey: null },
|
||||
{ to: "/chat", label: "Chat", icon: "💬", badgeKey: null },
|
||||
{ to: "/summaries", label: "Summaries", icon: "📅", badgeKey: null },
|
||||
{ to: "/security", label: "Security", icon: "🛡️", badgeKey: null },
|
||||
] as const;
|
||||
|
||||
export function DashboardLayout() {
|
||||
|
||||
207
frontend/src/components/TaskComments.tsx
Normal file
207
frontend/src/components/TaskComments.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { fetchComments, addComment, deleteComment, type TaskComment } from "../lib/api";
|
||||
import { useCurrentUser } from "../hooks/useCurrentUser";
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
||||
if (seconds < 60) return "just now";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString(undefined, {
|
||||
month: "short", day: "numeric",
|
||||
hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
// Avatar color based on name
|
||||
function avatarColor(name: string): string {
|
||||
const colors = [
|
||||
"bg-blue-500", "bg-green-500", "bg-purple-500", "bg-pink-500",
|
||||
"bg-indigo-500", "bg-teal-500", "bg-cyan-500", "bg-rose-500",
|
||||
];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
function avatarInitial(name: string): string {
|
||||
return name.charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
const proseClasses = "prose prose-sm prose-gray dark:prose-invert max-w-none [&_p]:mb-1 [&_p:last-child]:mb-0 [&_ul]:mb-1 [&_ol]:mb-1 [&_li]:mb-0 [&_code]:bg-gray-200 dark:[&_code]:bg-gray-700 [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_a]:text-amber-600 dark:[&_a]:text-amber-400 [&_a]:underline";
|
||||
|
||||
export function TaskComments({ taskId }: { taskId: string }) {
|
||||
const [comments, setComments] = useState<TaskComment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [commentText, setCommentText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
const loadComments = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchComments(taskId);
|
||||
setComments(data);
|
||||
} catch (e) {
|
||||
console.error("Failed to load comments:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadComments();
|
||||
// Poll for new comments every 30s
|
||||
const interval = setInterval(loadComments, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadComments]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const text = commentText.trim();
|
||||
if (!text) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await addComment(taskId, text);
|
||||
setCommentText("");
|
||||
await loadComments();
|
||||
// Reset textarea height
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "42px";
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to add comment:", e);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (commentId: string) => {
|
||||
setDeletingId(commentId);
|
||||
try {
|
||||
await deleteComment(taskId, commentId);
|
||||
setComments((prev) => prev.filter((c) => c.id !== commentId));
|
||||
} catch (e) {
|
||||
console.error("Failed to delete comment:", e);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const autoResize = () => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "42px";
|
||||
el.style.height = Math.min(el.scrollHeight, 160) + "px";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
|
||||
💬 Discussion {comments.length > 0 && (
|
||||
<span className="text-gray-300 dark:text-gray-600 ml-1">({comments.length})</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
{/* Comment input */}
|
||||
<div className="mb-4">
|
||||
<div className="flex gap-3">
|
||||
{user && (
|
||||
<div className={`w-8 h-8 rounded-full ${avatarColor(user.name)} flex items-center justify-center text-white text-sm font-bold shrink-0 mt-1`}>
|
||||
{avatarInitial(user.name)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={commentText}
|
||||
onChange={(e) => { setCommentText(e.target.value); autoResize(); }}
|
||||
placeholder="Leave a comment..."
|
||||
className="w-full text-sm border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 focus:border-amber-300 dark:focus:border-amber-700 resize-none placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
style={{ minHeight: "42px" }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500">Markdown supported · ⌘+Enter to submit</span>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!commentText.trim() || submitting}
|
||||
className="text-xs px-3 py-1.5 bg-amber-500 text-white rounded-lg font-medium hover:bg-amber-600 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{submitting ? "Posting..." : "Comment"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments list */}
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-400 dark:text-gray-500 py-6 text-sm">Loading comments...</div>
|
||||
) : comments.length === 0 ? (
|
||||
<div className="text-sm text-gray-400 dark:text-gray-500 italic py-6 text-center border-2 border-dashed border-gray-100 dark:border-gray-800 rounded-lg">
|
||||
No comments yet — be the first to chime in
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{comments.map((comment) => {
|
||||
const isOwn = user && comment.authorId === user.id;
|
||||
const isHammer = comment.authorId === "hammer";
|
||||
return (
|
||||
<div key={comment.id} className="flex gap-3 group">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shrink-0 mt-0.5 ${
|
||||
isHammer
|
||||
? "bg-amber-500 text-white"
|
||||
: `${avatarColor(comment.authorName)} text-white`
|
||||
}`}>
|
||||
{isHammer ? "🔨" : avatarInitial(comment.authorName)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className={`text-sm font-semibold ${
|
||||
isHammer ? "text-amber-700 dark:text-amber-400" : "text-gray-800 dark:text-gray-200"
|
||||
}`}>
|
||||
{comment.authorName}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500" title={formatDate(comment.createdAt)}>
|
||||
{timeAgo(comment.createdAt)}
|
||||
</span>
|
||||
{isOwn && (
|
||||
<button
|
||||
onClick={() => handleDelete(comment.id)}
|
||||
disabled={deletingId === comment.id}
|
||||
className="opacity-0 group-hover:opacity-100 text-gray-300 dark:text-gray-600 hover:text-red-400 dark:hover:text-red-400 transition text-xs"
|
||||
title="Delete comment"
|
||||
>
|
||||
{deletingId === comment.id ? "..." : "×"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={`text-sm text-gray-700 dark:text-gray-300 leading-relaxed ${proseClasses}`}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{comment.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { Task, TaskStatus, TaskPriority, TaskSource, Project, Recurrence } from "../lib/types";
|
||||
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api";
|
||||
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask, fetchComments, addComment, type TaskComment } from "../lib/api";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
const priorityColors: Record<TaskPriority, string> = {
|
||||
@@ -241,6 +241,90 @@ interface TaskDetailPanelProps {
|
||||
token: string;
|
||||
}
|
||||
|
||||
function CompactComments({ taskId }: { taskId: string }) {
|
||||
const [comments, setComments] = useState<TaskComment[]>([]);
|
||||
const [text, setText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchComments(taskId).then(setComments).catch(() => {});
|
||||
}, [taskId]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!text.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const comment = await addComment(taskId, text.trim());
|
||||
setComments((prev) => [...prev, comment]);
|
||||
setText("");
|
||||
} catch (e) {
|
||||
console.error("Failed to add comment:", e);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const recentComments = expanded ? comments : comments.slice(-3);
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 pt-4 mt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
|
||||
💬 Discussion {comments.length > 0 && <span className="text-gray-300 dark:text-gray-600">({comments.length})</span>}
|
||||
</h3>
|
||||
{comments.length > 3 && !expanded && (
|
||||
<button onClick={() => setExpanded(true)} className="text-[10px] text-amber-600 dark:text-amber-400 hover:text-amber-700 dark:hover:text-amber-300 font-medium">
|
||||
Show all ({comments.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
{recentComments.length > 0 && (
|
||||
<div className="space-y-2 mb-3">
|
||||
{recentComments.map((c) => (
|
||||
<div key={c.id} className="text-xs">
|
||||
<span className={`font-semibold ${c.authorId === "hammer" ? "text-amber-700 dark:text-amber-400" : "text-gray-700 dark:text-gray-300"}`}>
|
||||
{c.authorName}
|
||||
</span>
|
||||
<span className="text-gray-400 dark:text-gray-500 ml-1.5">
|
||||
{timeAgo(c.createdAt)}
|
||||
</span>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-0.5 leading-relaxed">{c.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Add a comment..."
|
||||
className="flex-1 text-xs border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-amber-200 dark:focus:ring-amber-800 placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!text.trim() || submitting}
|
||||
className="text-xs px-2.5 py-1.5 bg-amber-500 text-white rounded-lg font-medium hover:bg-amber-600 transition disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
|
||||
>
|
||||
{submitting ? "..." : "Post"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, hasToken, token }: TaskDetailPanelProps) {
|
||||
const actions = statusActions[task.status] || [];
|
||||
const isActive = task.status === "active";
|
||||
@@ -986,6 +1070,9 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Comments */}
|
||||
<CompactComments taskId={task.id} />
|
||||
</div>
|
||||
|
||||
{/* Save / Cancel Bar */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Task, Project, ProjectWithTasks, VelocityStats, Recurrence } from "./types";
|
||||
import type { Task, Project, ProjectWithTasks, VelocityStats, Recurrence, Todo, TodoPriority } from "./types";
|
||||
|
||||
const BASE = "/api/tasks";
|
||||
|
||||
@@ -167,6 +167,42 @@ export async function addProgressNote(taskId: string, note: string): Promise<Tas
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── Comments API ───
|
||||
|
||||
export interface TaskComment {
|
||||
id: string;
|
||||
taskId: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function fetchComments(taskId: string): Promise<TaskComment[]> {
|
||||
const res = await fetch(`${BASE}/${taskId}/comments`, { credentials: "include" });
|
||||
if (!res.ok) throw new Error("Failed to fetch comments");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function addComment(taskId: string, content: string): Promise<TaskComment> {
|
||||
const res = await fetch(`${BASE}/${taskId}/comments`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to add comment");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteComment(taskId: string, commentId: string): Promise<void> {
|
||||
const res = await fetch(`${BASE}/${taskId}/comments/${commentId}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete comment");
|
||||
}
|
||||
|
||||
// Admin API
|
||||
export async function fetchUsers(): Promise<any[]> {
|
||||
const res = await fetch("/api/admin/users", { credentials: "include" });
|
||||
@@ -192,3 +228,75 @@ export async function deleteUser(userId: string): Promise<void> {
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete user");
|
||||
}
|
||||
|
||||
// ─── Todos API ───
|
||||
|
||||
const TODOS_BASE = "/api/todos";
|
||||
|
||||
export async function fetchTodos(params?: { completed?: string; category?: string }): Promise<Todo[]> {
|
||||
const url = new URL(TODOS_BASE, window.location.origin);
|
||||
if (params?.completed) url.searchParams.set("completed", params.completed);
|
||||
if (params?.category) url.searchParams.set("category", params.category);
|
||||
const res = await fetch(url.toString(), { credentials: "include" });
|
||||
if (!res.ok) throw new Error(res.status === 401 ? "Unauthorized" : "Failed to fetch todos");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchTodoCategories(): Promise<string[]> {
|
||||
const res = await fetch(`${TODOS_BASE}/categories`, { credentials: "include" });
|
||||
if (!res.ok) throw new Error("Failed to fetch categories");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createTodo(todo: {
|
||||
title: string;
|
||||
description?: string;
|
||||
priority?: TodoPriority;
|
||||
category?: string;
|
||||
dueDate?: string | null;
|
||||
}): Promise<Todo> {
|
||||
const res = await fetch(TODOS_BASE, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(todo),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to create todo");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateTodo(id: string, updates: Partial<{
|
||||
title: string;
|
||||
description: string;
|
||||
priority: TodoPriority;
|
||||
category: string | null;
|
||||
dueDate: string | null;
|
||||
isCompleted: boolean;
|
||||
sortOrder: number;
|
||||
}>): Promise<Todo> {
|
||||
const res = await fetch(`${TODOS_BASE}/${id}`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to update todo");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function toggleTodo(id: string): Promise<Todo> {
|
||||
const res = await fetch(`${TODOS_BASE}/${id}/toggle`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to toggle todo");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteTodo(id: string): Promise<void> {
|
||||
const res = await fetch(`${TODOS_BASE}/${id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete todo");
|
||||
}
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* Chat WebSocket client for Hammer Dashboard
|
||||
*
|
||||
* Connects to the dashboard backend's WebSocket relay (which proxies to the Clawdbot gateway).
|
||||
* Authentication is handled via BetterAuth session cookie.
|
||||
*/
|
||||
|
||||
type MessageHandler = (msg: any) => void;
|
||||
type StateHandler = (state: "connecting" | "connected" | "disconnected") => void;
|
||||
|
||||
let reqCounter = 0;
|
||||
function nextId() {
|
||||
return `r${++reqCounter}`;
|
||||
}
|
||||
|
||||
export class ChatClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private state: "connecting" | "connected" | "disconnected" = "disconnected";
|
||||
private pendingRequests = new Map<string, { resolve: (v: any) => void; reject: (e: any) => void }>();
|
||||
private eventHandlers = new Map<string, Set<MessageHandler>>();
|
||||
private stateHandlers = new Set<StateHandler>();
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private shouldReconnect = true;
|
||||
|
||||
connect() {
|
||||
this.shouldReconnect = true;
|
||||
this._connect();
|
||||
}
|
||||
|
||||
private _connect() {
|
||||
if (this.ws) {
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
|
||||
// Build WebSocket URL from current page origin
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
// Backend is at the same origin via nginx proxy on Dokploy
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/chat/ws`;
|
||||
|
||||
this.setState("connecting");
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
// Send auth message with session cookie
|
||||
this._send({
|
||||
type: "auth",
|
||||
cookie: document.cookie,
|
||||
});
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
this._handleMessage(msg);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse message:", e);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.setState("disconnected");
|
||||
if (this.shouldReconnect) {
|
||||
this.reconnectTimer = setTimeout(() => this._connect(), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
// onclose handles reconnect
|
||||
};
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.shouldReconnect = false;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.ws) {
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
this.ws = null;
|
||||
this.setState("disconnected");
|
||||
}
|
||||
|
||||
isConnected() {
|
||||
return this.state === "connected";
|
||||
}
|
||||
|
||||
getState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
onStateChange(handler: StateHandler): () => void {
|
||||
this.stateHandlers.add(handler);
|
||||
return () => { this.stateHandlers.delete(handler); };
|
||||
}
|
||||
|
||||
on(event: string, handler: MessageHandler): () => void {
|
||||
if (!this.eventHandlers.has(event)) {
|
||||
this.eventHandlers.set(event, new Set());
|
||||
}
|
||||
this.eventHandlers.get(event)!.add(handler);
|
||||
return () => { this.eventHandlers.get(event)?.delete(handler); };
|
||||
}
|
||||
|
||||
async request(method: string, params?: any): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.ws || this.state !== "connected") {
|
||||
reject(new Error("Not connected"));
|
||||
return;
|
||||
}
|
||||
const id = nextId();
|
||||
this.pendingRequests.set(id, { resolve, reject });
|
||||
this._send({ type: method, id, ...params });
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.pendingRequests.has(id)) {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error("Request timeout"));
|
||||
}
|
||||
}, 120000);
|
||||
});
|
||||
}
|
||||
|
||||
// Chat methods
|
||||
async chatHistory(sessionKey: string, limit = 50) {
|
||||
return this.request("chat.history", { sessionKey, limit });
|
||||
}
|
||||
|
||||
async chatSend(sessionKey: string, message: string) {
|
||||
return this.request("chat.send", {
|
||||
sessionKey,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
async chatAbort(sessionKey: string) {
|
||||
return this.request("chat.abort", { sessionKey });
|
||||
}
|
||||
|
||||
async sessionsList(limit = 50) {
|
||||
return this.request("sessions.list", { limit });
|
||||
}
|
||||
|
||||
private setState(state: "connecting" | "connected" | "disconnected") {
|
||||
this.state = state;
|
||||
this.stateHandlers.forEach((h) => h(state));
|
||||
}
|
||||
|
||||
private _send(msg: any) {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
private _handleMessage(msg: any) {
|
||||
// Auth response
|
||||
if (msg.type === "auth_ok") {
|
||||
this.setState("connected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "error" && this.state !== "connected") {
|
||||
console.error("Auth failed:", msg.error);
|
||||
this.shouldReconnect = false;
|
||||
this.ws?.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Request response
|
||||
if (msg.type === "res") {
|
||||
const pending = this.pendingRequests.get(msg.id);
|
||||
if (pending) {
|
||||
this.pendingRequests.delete(msg.id);
|
||||
if (msg.ok) {
|
||||
pending.resolve(msg.payload);
|
||||
} else {
|
||||
pending.reject(new Error(msg.error || "Request failed"));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Error for a specific request
|
||||
if (msg.type === "error" && msg.id) {
|
||||
const pending = this.pendingRequests.get(msg.id);
|
||||
if (pending) {
|
||||
this.pendingRequests.delete(msg.id);
|
||||
pending.reject(new Error(msg.error || "Request failed"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Gateway events (forwarded from backend)
|
||||
if (msg.type === "event") {
|
||||
const handlers = this.eventHandlers.get(msg.event);
|
||||
if (handlers) {
|
||||
handlers.forEach((h) => h(msg.payload));
|
||||
}
|
||||
const wildcardHandlers = this.eventHandlers.get("*");
|
||||
if (wildcardHandlers) {
|
||||
wildcardHandlers.forEach((h) => h({ event: msg.event, ...msg.payload }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
let _client: ChatClient | null = null;
|
||||
|
||||
export function getChatClient(): ChatClient {
|
||||
if (!_client) {
|
||||
_client = new ChatClient();
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
@@ -51,6 +51,27 @@ export interface Recurrence {
|
||||
autoActivate?: boolean;
|
||||
}
|
||||
|
||||
// ─── Personal Todos ───
|
||||
|
||||
export type TodoPriority = "high" | "medium" | "low" | "none";
|
||||
|
||||
export interface Todo {
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
isCompleted: boolean;
|
||||
priority: TodoPriority;
|
||||
category: string | null;
|
||||
dueDate: string | null;
|
||||
completedAt: string | null;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ─── Tasks ───
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
taskNumber: number;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTasks } from "../hooks/useTasks";
|
||||
import type { Task, ProgressNote } from "../lib/types";
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
||||
@@ -26,55 +24,89 @@ function formatDate(dateStr: string): string {
|
||||
}
|
||||
|
||||
interface ActivityItem {
|
||||
task: Task;
|
||||
note: ProgressNote;
|
||||
type: "progress" | "comment";
|
||||
timestamp: string;
|
||||
taskId: string;
|
||||
taskNumber: number | null;
|
||||
taskTitle: string;
|
||||
taskStatus: string;
|
||||
note?: string;
|
||||
commentId?: string;
|
||||
authorName?: string;
|
||||
authorId?: string | null;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
interface ActivityGroup {
|
||||
date: string;
|
||||
items: ActivityItem[];
|
||||
}
|
||||
|
||||
function avatarColor(name: string): string {
|
||||
const colors = [
|
||||
"bg-blue-500", "bg-green-500", "bg-purple-500", "bg-pink-500",
|
||||
"bg-indigo-500", "bg-teal-500", "bg-cyan-500", "bg-rose-500",
|
||||
];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
export function ActivityPage() {
|
||||
const { tasks, loading } = useTasks(15000);
|
||||
const [items, setItems] = useState<ActivityItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [_total, setTotal] = useState(0);
|
||||
const [filter, setFilter] = useState<string>("all");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
|
||||
const allActivity = useMemo(() => {
|
||||
const items: ActivityItem[] = [];
|
||||
for (const task of tasks) {
|
||||
if (task.progressNotes) {
|
||||
for (const note of task.progressNotes) {
|
||||
items.push({ task, note });
|
||||
}
|
||||
}
|
||||
const fetchActivity = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/activity?limit=200", { credentials: "include" });
|
||||
if (!res.ok) throw new Error("Failed to fetch activity");
|
||||
const data = await res.json();
|
||||
setItems(data.items || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch activity:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
items.sort(
|
||||
(a, b) =>
|
||||
new Date(b.note.timestamp).getTime() - new Date(a.note.timestamp).getTime()
|
||||
);
|
||||
return items;
|
||||
}, [tasks]);
|
||||
}, []);
|
||||
|
||||
const groupedActivity = useMemo(() => {
|
||||
const filtered =
|
||||
filter === "all"
|
||||
? allActivity
|
||||
: allActivity.filter((a) => a.task.status === filter);
|
||||
useEffect(() => {
|
||||
fetchActivity();
|
||||
const interval = setInterval(fetchActivity, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchActivity]);
|
||||
|
||||
const groups: { date: string; items: ActivityItem[] }[] = [];
|
||||
let currentDate = "";
|
||||
for (const item of filtered) {
|
||||
const d = new Date(item.note.timestamp).toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
if (d !== currentDate) {
|
||||
currentDate = d;
|
||||
groups.push({ date: d, items: [] });
|
||||
}
|
||||
groups[groups.length - 1].items.push(item);
|
||||
// Apply filters
|
||||
const filtered = items.filter((item) => {
|
||||
if (filter !== "all" && item.taskStatus !== filter) return false;
|
||||
if (typeFilter !== "all" && item.type !== typeFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Group by date
|
||||
const grouped: ActivityGroup[] = [];
|
||||
let currentDate = "";
|
||||
for (const item of filtered) {
|
||||
const d = new Date(item.timestamp).toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
if (d !== currentDate) {
|
||||
currentDate = d;
|
||||
grouped.push({ date: d, items: [] });
|
||||
}
|
||||
return groups;
|
||||
}, [allActivity, filter]);
|
||||
grouped[grouped.length - 1].items.push(item);
|
||||
}
|
||||
|
||||
if (loading && tasks.length === 0) {
|
||||
const commentCount = items.filter(i => i.type === "comment").length;
|
||||
const progressCount = items.filter(i => i.type === "progress").length;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center text-gray-400 dark:text-gray-500">
|
||||
Loading activity...
|
||||
@@ -85,33 +117,54 @@ export function ActivityPage() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 sticky top-14 md:top-0 z-30">
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">📝 Activity Log</h1>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
{allActivity.length} updates across {tasks.length} tasks
|
||||
</p>
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">📝 Activity Log</h1>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
{progressCount} updates · {commentCount} comments
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="text-xs border border-gray-200 dark:border-gray-700 rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="queued">Queued</option>
|
||||
<option value="blocked">Blocked</option>
|
||||
</select>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="text-xs border border-gray-200 dark:border-gray-700 rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="progress">🔨 Progress Notes</option>
|
||||
<option value="comment">💬 Comments</option>
|
||||
</select>
|
||||
{(filter !== "all" || typeFilter !== "all") && (
|
||||
<button
|
||||
onClick={() => { setFilter("all"); setTypeFilter("all"); }}
|
||||
className="text-xs text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="text-sm border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="all">All Tasks</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="queued">Queued</option>
|
||||
<option value="blocked">Blocked</option>
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-6">
|
||||
{groupedActivity.length === 0 ? (
|
||||
{grouped.length === 0 ? (
|
||||
<div className="text-center text-gray-400 dark:text-gray-500 py-12">No activity found</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{groupedActivity.map((group) => (
|
||||
{grouped.map((group) => (
|
||||
<div key={group.date}>
|
||||
<div className="sticky top-28 md:top-14 z-10 bg-gray-50/95 dark:bg-gray-950/95 backdrop-blur-sm py-2 mb-3">
|
||||
<h2 className="text-sm font-semibold text-gray-500 dark:text-gray-400">{group.date}</h2>
|
||||
@@ -119,11 +172,21 @@ export function ActivityPage() {
|
||||
<div className="space-y-1">
|
||||
{group.items.map((item, i) => (
|
||||
<div
|
||||
key={`${item.task.id}-${i}`}
|
||||
key={`${item.taskId}-${item.type}-${i}`}
|
||||
className="flex gap-3 py-3 px-3 rounded-lg hover:bg-white dark:hover:bg-gray-900 transition group"
|
||||
>
|
||||
<div className="flex flex-col items-center pt-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-amber-400 shrink-0" />
|
||||
{item.type === "comment" ? (
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white shrink-0 ${
|
||||
item.authorId === "hammer"
|
||||
? "bg-amber-500"
|
||||
: avatarColor(item.authorName || "?")
|
||||
}`}>
|
||||
{item.authorId === "hammer" ? "🔨" : (item.authorName || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-amber-400 shrink-0 mt-1.5" />
|
||||
)}
|
||||
{i < group.items.length - 1 && (
|
||||
<div className="w-px flex-1 bg-gray-200 dark:bg-gray-700 mt-1" />
|
||||
)}
|
||||
@@ -132,23 +195,35 @@ export function ActivityPage() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<Link
|
||||
to={`/task/HQ-${item.task.taskNumber}`}
|
||||
to={`/task/HQ-${item.taskNumber}`}
|
||||
className="text-xs font-bold text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/30 px-1.5 py-0.5 rounded font-mono hover:bg-amber-100 dark:hover:bg-amber-900/50 transition"
|
||||
>
|
||||
HQ-{item.task.taskNumber}
|
||||
HQ-{item.taskNumber}
|
||||
</Link>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
||||
item.type === "comment"
|
||||
? "bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400"
|
||||
: "bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400"
|
||||
}`}>
|
||||
{item.type === "comment" ? "💬 comment" : "🔨 progress"}
|
||||
</span>
|
||||
{item.type === "comment" && item.authorName && (
|
||||
<span className="text-[10px] text-gray-500 dark:text-gray-400 font-medium">
|
||||
by {item.authorName}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500">
|
||||
{formatDate(item.note.timestamp)}
|
||||
{formatDate(item.timestamp)}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-300 dark:text-gray-600">
|
||||
({timeAgo(item.note.timestamp)})
|
||||
({timeAgo(item.timestamp)})
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
|
||||
{item.note.note}
|
||||
{item.type === "comment" ? item.content : item.note}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1 truncate">
|
||||
{item.task.title}
|
||||
{item.taskTitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,751 +0,0 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { getChatClient, type ChatClient } from "../lib/gateway";
|
||||
|
||||
interface ChatMessage {
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
interface ChatThread {
|
||||
sessionKey: string;
|
||||
name: string;
|
||||
lastMessage?: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface GatewaySession {
|
||||
sessionKey: string;
|
||||
kind?: string;
|
||||
channel?: string;
|
||||
lastActivity?: string;
|
||||
messageCount?: number;
|
||||
}
|
||||
|
||||
function ThreadList({
|
||||
threads,
|
||||
activeThread,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onRename,
|
||||
onDelete,
|
||||
onClose,
|
||||
onBrowseSessions,
|
||||
gatewaySessions,
|
||||
loadingGatewaySessions,
|
||||
showGatewaySessions,
|
||||
onToggleGatewaySessions,
|
||||
}: {
|
||||
threads: ChatThread[];
|
||||
activeThread: string | null;
|
||||
onSelect: (key: string) => void;
|
||||
onCreate: () => void;
|
||||
onRename?: (key: string, name: string) => void;
|
||||
onDelete?: (key: string) => void;
|
||||
onClose?: () => void;
|
||||
onBrowseSessions?: () => void;
|
||||
gatewaySessions?: GatewaySession[];
|
||||
loadingGatewaySessions?: boolean;
|
||||
showGatewaySessions?: boolean;
|
||||
onToggleGatewaySessions?: () => void;
|
||||
}) {
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
|
||||
const startRename = (key: string, currentName: string) => {
|
||||
setEditingKey(key);
|
||||
setEditName(currentName);
|
||||
};
|
||||
|
||||
const commitRename = () => {
|
||||
if (editingKey && editName.trim() && onRename) {
|
||||
onRename(editingKey, editName.trim());
|
||||
}
|
||||
setEditingKey(null);
|
||||
};
|
||||
|
||||
// Check if a session key exists in local threads already
|
||||
const localSessionKeys = new Set(threads.map(t => t.sessionKey));
|
||||
|
||||
return (
|
||||
<div className="w-full sm:w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col h-full">
|
||||
<div className="p-3 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-600 dark:text-gray-400">Threads</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onCreate}
|
||||
className="text-xs bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600 transition font-medium"
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="sm:hidden text-gray-400 hover:text-gray-600 p-1"
|
||||
aria-label="Close threads"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Local threads */}
|
||||
{threads.length === 0 && !showGatewaySessions ? (
|
||||
<div className="p-4 text-sm text-gray-400 dark:text-gray-500 text-center">
|
||||
No threads yet
|
||||
</div>
|
||||
) : (
|
||||
threads.map((thread) => (
|
||||
<div
|
||||
key={thread.sessionKey}
|
||||
className={`group relative w-full text-left px-3 py-3 border-b border-gray-50 dark:border-gray-800 transition cursor-pointer ${
|
||||
activeThread === thread.sessionKey
|
||||
? "bg-amber-50 dark:bg-amber-900/20 border-l-2 border-l-amber-500"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
onClick={() => onSelect(thread.sessionKey)}
|
||||
>
|
||||
{editingKey === thread.sessionKey ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="text-sm font-medium text-gray-800 dark:text-gray-200 w-full bg-white dark:bg-gray-800 border border-amber-300 dark:border-amber-700 rounded px-1 py-0.5 outline-none"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitRename();
|
||||
if (e.key === "Escape") setEditingKey(null);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="text-sm font-medium text-gray-800 dark:text-gray-200 truncate pr-6"
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startRename(thread.sessionKey, thread.name);
|
||||
}}
|
||||
>
|
||||
{thread.name}
|
||||
</div>
|
||||
)}
|
||||
{thread.lastMessage && (
|
||||
<div className="text-xs text-gray-400 truncate mt-0.5">
|
||||
{thread.lastMessage}
|
||||
</div>
|
||||
)}
|
||||
{onDelete && editingKey !== thread.sessionKey && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(thread.sessionKey);
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 text-gray-300 hover:text-red-400 transition p-1"
|
||||
aria-label="Delete thread"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Gateway sessions browser */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-800">
|
||||
<button
|
||||
onClick={() => {
|
||||
onToggleGatewaySessions?.();
|
||||
if (!showGatewaySessions) onBrowseSessions?.();
|
||||
}}
|
||||
className="w-full px-3 py-2.5 text-xs font-semibold text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition flex items-center justify-between"
|
||||
>
|
||||
<span>🔌 Gateway Sessions</span>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 transition-transform ${showGatewaySessions ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{showGatewaySessions && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50">
|
||||
{loadingGatewaySessions ? (
|
||||
<div className="px-3 py-3 text-xs text-gray-400 dark:text-gray-500 text-center">Loading sessions...</div>
|
||||
) : !gatewaySessions || gatewaySessions.length === 0 ? (
|
||||
<div className="px-3 py-3 text-xs text-gray-400 dark:text-gray-500 text-center">No sessions found</div>
|
||||
) : (
|
||||
gatewaySessions.filter(s => !localSessionKeys.has(s.sessionKey)).map((session) => (
|
||||
<div
|
||||
key={session.sessionKey}
|
||||
className="px-3 py-2.5 border-b border-gray-100 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition"
|
||||
onClick={() => onSelect(session.sessionKey)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs">
|
||||
{session.channel === "telegram" ? "📱" : session.kind === "cron" ? "⏰" : "💬"}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 truncate">
|
||||
{session.sessionKey}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{session.channel && (
|
||||
<span className="text-[10px] text-gray-400">{session.channel}</span>
|
||||
)}
|
||||
{session.kind && (
|
||||
<span className="text-[10px] text-gray-400">{session.kind}</span>
|
||||
)}
|
||||
{session.messageCount != null && (
|
||||
<span className="text-[10px] text-gray-400">{session.messageCount} msgs</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTimestamp(ts?: number): string {
|
||||
if (!ts) return "";
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function MessageBubble({ msg }: { msg: ChatMessage }) {
|
||||
const [showTime, setShowTime] = useState(false);
|
||||
const isUser = msg.role === "user";
|
||||
const isSystem = msg.role === "system";
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div className="text-center my-2">
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800 px-3 py-1 rounded-full">
|
||||
{msg.content}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex ${isUser ? "justify-end" : "justify-start"} mb-3 group`}
|
||||
onClick={() => setShowTime(!showTime)}
|
||||
>
|
||||
{!isUser && (
|
||||
<div className="w-8 h-8 bg-amber-500 rounded-full flex items-center justify-center text-sm mr-2 shrink-0 mt-1">
|
||||
🔨
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div
|
||||
className={`max-w-[75vw] sm:max-w-[60vw] rounded-2xl px-4 py-2.5 ${
|
||||
isUser
|
||||
? "bg-blue-500 text-white rounded-br-md"
|
||||
: "bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-bl-md"
|
||||
}`}
|
||||
>
|
||||
{isUser ? (
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">{msg.content}</p>
|
||||
) : (
|
||||
<div className="text-sm leading-relaxed prose prose-sm prose-gray max-w-none [&_pre]:bg-gray-800 [&_pre]:text-gray-100 [&_pre]:rounded-lg [&_pre]:p-3 [&_pre]:overflow-x-auto [&_pre]:text-xs [&_code]:bg-gray-200 [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_p]:mb-2 [&_p:last-child]:mb-0 [&_ul]:mb-2 [&_ol]:mb-2 [&_li]:mb-0.5 [&_h1]:text-base [&_h2]:text-sm [&_h3]:text-sm [&_a]:text-amber-600 [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-gray-300 [&_blockquote]:pl-3 [&_blockquote]:text-gray-500">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{msg.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showTime && msg.timestamp && (
|
||||
<span className={`text-[10px] text-gray-400 mt-0.5 ${isUser ? "text-right" : "text-left"}`}>
|
||||
{formatTimestamp(msg.timestamp)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThinkingIndicator() {
|
||||
return (
|
||||
<div className="flex justify-start mb-3">
|
||||
<div className="w-8 h-8 bg-amber-500 rounded-full flex items-center justify-center text-sm mr-2 shrink-0 mt-1">
|
||||
🔨
|
||||
</div>
|
||||
<div className="bg-gray-100 dark:bg-gray-800 rounded-2xl rounded-bl-md px-4 py-3">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
|
||||
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
|
||||
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatArea({
|
||||
messages,
|
||||
loading,
|
||||
streaming,
|
||||
thinking,
|
||||
streamText,
|
||||
onSend,
|
||||
onAbort,
|
||||
connectionState,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
loading: boolean;
|
||||
streaming: boolean;
|
||||
thinking: boolean;
|
||||
streamText: string;
|
||||
onSend: (msg: string) => void;
|
||||
onAbort: () => void;
|
||||
connectionState: "connecting" | "connected" | "disconnected";
|
||||
}) {
|
||||
const [input, setInput] = useState("");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, streamText, thinking]);
|
||||
|
||||
// Auto-resize textarea
|
||||
const autoResize = useCallback(() => {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "42px";
|
||||
el.style.height = Math.min(el.scrollHeight, 128) + "px";
|
||||
}, []);
|
||||
|
||||
const handleSend = () => {
|
||||
const text = input.trim();
|
||||
if (!text || connectionState !== "connected") return;
|
||||
onSend(text);
|
||||
setInput("");
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const connected = connectionState === "connected";
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full">
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-400 dark:text-gray-500 py-12">Loading messages...</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="text-center text-gray-400 dark:text-gray-500 py-12">
|
||||
<span className="text-4xl block mb-3">🔨</span>
|
||||
<p className="text-sm">Send a message to start chatting with Hammer</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((msg, i) => (
|
||||
<MessageBubble key={i} msg={msg} />
|
||||
))}
|
||||
{streaming && streamText && (
|
||||
<MessageBubble msg={{ role: "assistant", content: streamText }} />
|
||||
)}
|
||||
{thinking && !streaming && <ThinkingIndicator />}
|
||||
</>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-3">
|
||||
{connectionState === "disconnected" && (
|
||||
<div className="text-xs text-red-500 mb-2 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-red-500 rounded-full" />
|
||||
Disconnected — reconnecting...
|
||||
</div>
|
||||
)}
|
||||
{connectionState === "connecting" && (
|
||||
<div className="text-xs text-amber-500 mb-2 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-amber-500 rounded-full animate-pulse" />
|
||||
Connecting...
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 items-end">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => { setInput(e.target.value); autoResize(); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={connected ? "Type a message..." : "Connecting..."}
|
||||
disabled={!connected}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 focus:border-amber-300 dark:focus:border-amber-700 disabled:opacity-50 max-h-32 placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
style={{ minHeight: "42px" }}
|
||||
/>
|
||||
{streaming ? (
|
||||
<button
|
||||
onClick={onAbort}
|
||||
className="px-4 py-2.5 bg-red-500 text-white rounded-xl text-sm font-medium hover:bg-red-600 transition shrink-0"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || !connected}
|
||||
className="px-4 py-2.5 bg-amber-500 text-white rounded-xl text-sm font-medium hover:bg-amber-600 transition disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatPage() {
|
||||
const [client] = useState<ChatClient>(() => getChatClient());
|
||||
const [connectionState, setConnectionState] = useState<"connecting" | "connected" | "disconnected">("disconnected");
|
||||
const [threads, setThreads] = useState<ChatThread[]>(() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("hammer-chat-threads") || "[]");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const [activeThread, setActiveThread] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [thinking, setThinking] = useState(false);
|
||||
const [streamText, setStreamText] = useState("");
|
||||
const [showThreads, setShowThreads] = useState(false);
|
||||
const [gatewaySessions, setGatewaySessions] = useState<GatewaySession[]>([]);
|
||||
const [loadingGatewaySessions, setLoadingGatewaySessions] = useState(false);
|
||||
const [showGatewaySessions, setShowGatewaySessions] = useState(false);
|
||||
|
||||
// Persist threads to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem("hammer-chat-threads", JSON.stringify(threads));
|
||||
}, [threads]);
|
||||
|
||||
// Connect client
|
||||
useEffect(() => {
|
||||
client.connect();
|
||||
const unsub = client.onStateChange(setConnectionState);
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
// Listen for chat events (streaming responses)
|
||||
useEffect(() => {
|
||||
const unsub = client.on("chat", (payload: any) => {
|
||||
if (payload.sessionKey !== activeThread) return;
|
||||
|
||||
if (payload.state === "delta" && payload.message?.content) {
|
||||
setThinking(false);
|
||||
setStreaming(true);
|
||||
const textParts = payload.message.content
|
||||
.filter((c: any) => c.type === "text")
|
||||
.map((c: any) => c.text)
|
||||
.join("");
|
||||
if (textParts) {
|
||||
setStreamText((prev) => prev + textParts);
|
||||
}
|
||||
} else if (payload.state === "final") {
|
||||
setThinking(false);
|
||||
if (payload.message?.content) {
|
||||
const text = payload.message.content
|
||||
.filter((c: any) => c.type === "text")
|
||||
.map((c: any) => c.text)
|
||||
.join("");
|
||||
if (text) {
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: text, timestamp: Date.now() }]);
|
||||
setThreads((prev) =>
|
||||
prev.map((t) =>
|
||||
t.sessionKey === activeThread
|
||||
? { ...t, lastMessage: text.slice(0, 100), updatedAt: Date.now() }
|
||||
: t
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
setStreaming(false);
|
||||
setStreamText("");
|
||||
} else if (payload.state === "aborted" || payload.state === "error") {
|
||||
setThinking(false);
|
||||
setStreaming(false);
|
||||
if (streamText) {
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: streamText }]);
|
||||
}
|
||||
setStreamText("");
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
}, [client, activeThread, streamText]);
|
||||
|
||||
// Load messages when thread changes
|
||||
const loadMessages = useCallback(
|
||||
async (sessionKey: string) => {
|
||||
setLoading(true);
|
||||
setMessages([]);
|
||||
try {
|
||||
const result = await client.chatHistory(sessionKey);
|
||||
if (result?.messages) {
|
||||
const msgs: ChatMessage[] = result.messages
|
||||
.filter((m: any) => m.role === "user" || m.role === "assistant")
|
||||
.map((m: any) => ({
|
||||
role: m.role as "user" | "assistant",
|
||||
content:
|
||||
typeof m.content === "string"
|
||||
? m.content
|
||||
: Array.isArray(m.content)
|
||||
? m.content
|
||||
.filter((c: any) => c.type === "text")
|
||||
.map((c: any) => c.text)
|
||||
.join("")
|
||||
: "",
|
||||
}))
|
||||
.filter((m: ChatMessage) => m.content);
|
||||
setMessages(msgs);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load chat history:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[client]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeThread && connectionState === "connected") {
|
||||
loadMessages(activeThread);
|
||||
}
|
||||
}, [activeThread, connectionState, loadMessages]);
|
||||
|
||||
const handleBrowseSessions = useCallback(async () => {
|
||||
if (!client.isConnected()) return;
|
||||
setLoadingGatewaySessions(true);
|
||||
try {
|
||||
const result = await client.sessionsList(50);
|
||||
if (result?.sessions) {
|
||||
setGatewaySessions(result.sessions.map((s: any) => ({
|
||||
sessionKey: s.sessionKey || s.key,
|
||||
kind: s.kind,
|
||||
channel: s.channel,
|
||||
lastActivity: s.lastActivity,
|
||||
messageCount: s.messageCount,
|
||||
})));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load sessions:", e);
|
||||
} finally {
|
||||
setLoadingGatewaySessions(false);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
const handleCreateThread = () => {
|
||||
const id = `dash:chat:${Date.now()}`;
|
||||
const thread: ChatThread = {
|
||||
sessionKey: id,
|
||||
name: `Chat ${threads.length + 1}`,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
setThreads((prev) => [thread, ...prev]);
|
||||
setActiveThread(id);
|
||||
setMessages([]);
|
||||
};
|
||||
|
||||
const handleSelectThread = (sessionKey: string) => {
|
||||
// If it's a gateway session not in local threads, add it
|
||||
if (!threads.find(t => t.sessionKey === sessionKey)) {
|
||||
const gwSession = gatewaySessions.find(s => s.sessionKey === sessionKey);
|
||||
const thread: ChatThread = {
|
||||
sessionKey,
|
||||
name: gwSession?.channel
|
||||
? `${gwSession.channel} (${sessionKey.slice(0, 12)}...)`
|
||||
: sessionKey.length > 20
|
||||
? `${sessionKey.slice(0, 20)}...`
|
||||
: sessionKey,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
setThreads((prev) => [thread, ...prev]);
|
||||
}
|
||||
setActiveThread(sessionKey);
|
||||
};
|
||||
|
||||
const handleRenameThread = (key: string, name: string) => {
|
||||
setThreads((prev) => prev.map((t) => (t.sessionKey === key ? { ...t, name } : t)));
|
||||
};
|
||||
|
||||
const handleDeleteThread = (key: string) => {
|
||||
setThreads((prev) => prev.filter((t) => t.sessionKey !== key));
|
||||
if (activeThread === key) {
|
||||
const remaining = threads.filter((t) => t.sessionKey !== key);
|
||||
setActiveThread(remaining.length > 0 ? remaining[0].sessionKey : null);
|
||||
setMessages([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async (text: string) => {
|
||||
if (!activeThread) return;
|
||||
|
||||
setMessages((prev) => [...prev, { role: "user", content: text, timestamp: Date.now() }]);
|
||||
setThinking(true);
|
||||
setThreads((prev) =>
|
||||
prev.map((t) =>
|
||||
t.sessionKey === activeThread
|
||||
? { ...t, lastMessage: text.slice(0, 100), updatedAt: Date.now() }
|
||||
: t
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
await client.chatSend(activeThread, text);
|
||||
} catch (e) {
|
||||
console.error("Failed to send:", e);
|
||||
setThinking(false);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "system", content: "Failed to send message. Please try again." },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAbort = async () => {
|
||||
if (!activeThread) return;
|
||||
try {
|
||||
await client.chatAbort(activeThread);
|
||||
} catch (e) {
|
||||
console.error("Failed to abort:", e);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-create first thread if none exist
|
||||
useEffect(() => {
|
||||
if (threads.length === 0) {
|
||||
handleCreateThread();
|
||||
} else if (!activeThread) {
|
||||
setActiveThread(threads[0].sessionKey);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-3.5rem)] md:h-screen flex flex-col">
|
||||
{/* Page Header */}
|
||||
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 sticky top-0 z-30">
|
||||
<div className="px-4 sm:px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowThreads(!showThreads)}
|
||||
className="sm:hidden p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition"
|
||||
aria-label="Toggle threads"
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h8m-8 6h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-lg font-bold text-gray-900 dark:text-gray-100">Chat</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
connectionState === "connected"
|
||||
? "bg-green-500"
|
||||
: connectionState === "connecting"
|
||||
? "bg-amber-500 animate-pulse"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
{connectionState === "connected" ? "Connected" : connectionState === "connecting" ? "Connecting..." : "Disconnected"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Chat body */}
|
||||
<div className="flex-1 flex overflow-hidden relative">
|
||||
{/* Thread list */}
|
||||
<div className={`
|
||||
absolute inset-0 z-20 sm:relative sm:inset-auto sm:z-auto
|
||||
${showThreads ? "block" : "hidden"} sm:block
|
||||
`}>
|
||||
{showThreads && (
|
||||
<div
|
||||
className="absolute inset-0 bg-black/30 sm:hidden"
|
||||
onClick={() => setShowThreads(false)}
|
||||
/>
|
||||
)}
|
||||
<div className="relative z-10 h-full">
|
||||
<ThreadList
|
||||
threads={threads}
|
||||
activeThread={activeThread}
|
||||
onSelect={(key) => {
|
||||
handleSelectThread(key);
|
||||
setShowThreads(false);
|
||||
}}
|
||||
onCreate={() => {
|
||||
handleCreateThread();
|
||||
setShowThreads(false);
|
||||
}}
|
||||
onRename={handleRenameThread}
|
||||
onDelete={handleDeleteThread}
|
||||
onClose={() => setShowThreads(false)}
|
||||
onBrowseSessions={handleBrowseSessions}
|
||||
gatewaySessions={gatewaySessions}
|
||||
loadingGatewaySessions={loadingGatewaySessions}
|
||||
showGatewaySessions={showGatewaySessions}
|
||||
onToggleGatewaySessions={() => setShowGatewaySessions(!showGatewaySessions)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{activeThread ? (
|
||||
<ChatArea
|
||||
messages={messages}
|
||||
loading={loading}
|
||||
streaming={streaming}
|
||||
thinking={thinking}
|
||||
streamText={streamText}
|
||||
onSend={handleSend}
|
||||
onAbort={handleAbort}
|
||||
connectionState={connectionState}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 p-4 text-center">
|
||||
<div>
|
||||
<span className="text-3xl block mb-2">💬</span>
|
||||
<p>Select or create a thread</p>
|
||||
<button
|
||||
onClick={() => setShowThreads(true)}
|
||||
className="sm:hidden mt-3 text-sm text-amber-500 font-medium"
|
||||
>
|
||||
View Threads →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1023
frontend/src/pages/SecurityPage.tsx
Normal file
1023
frontend/src/pages/SecurityPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
445
frontend/src/pages/SummariesPage.tsx
Normal file
445
frontend/src/pages/SummariesPage.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
interface SummaryStats {
|
||||
deploys?: number;
|
||||
commits?: number;
|
||||
tasksCompleted?: number;
|
||||
featuresBuilt?: number;
|
||||
bugsFixed?: number;
|
||||
[key: string]: number | undefined;
|
||||
}
|
||||
|
||||
interface SummaryHighlight {
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface DailySummary {
|
||||
id: string;
|
||||
date: string;
|
||||
content: string;
|
||||
highlights: SummaryHighlight[];
|
||||
stats: SummaryStats;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const STAT_ICONS: Record<string, string> = {
|
||||
deploys: "🚀",
|
||||
commits: "📦",
|
||||
tasksCompleted: "✅",
|
||||
featuresBuilt: "🛠️",
|
||||
bugsFixed: "🐛",
|
||||
};
|
||||
|
||||
const STAT_LABELS: Record<string, string> = {
|
||||
deploys: "Deploys",
|
||||
commits: "Commits",
|
||||
tasksCompleted: "Tasks",
|
||||
featuresBuilt: "Features",
|
||||
bugsFixed: "Fixes",
|
||||
};
|
||||
|
||||
function formatDateDisplay(dateStr: string): string {
|
||||
const d = new Date(dateStr + "T12:00:00Z");
|
||||
return d.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatShort(dateStr: string): string {
|
||||
const d = new Date(dateStr + "T12:00:00Z");
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function getDaysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
}
|
||||
|
||||
function getFirstDayOfMonth(year: number, month: number): number {
|
||||
return new Date(year, month, 1).getDay();
|
||||
}
|
||||
|
||||
function toDateStr(y: number, m: number, d: number): string {
|
||||
return `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function todayStr(): string {
|
||||
const d = new Date();
|
||||
return toDateStr(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
}
|
||||
|
||||
function addDays(dateStr: string, days: number): string {
|
||||
const d = new Date(dateStr + "T12:00:00Z");
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
return toDateStr(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
|
||||
}
|
||||
|
||||
export function SummariesPage() {
|
||||
const [summaryDates, setSummaryDates] = useState<Set<string>>(new Set());
|
||||
const [selectedDate, setSelectedDate] = useState<string>(todayStr());
|
||||
const [summary, setSummary] = useState<DailySummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingSummary, setLoadingSummary] = useState(false);
|
||||
const [calMonth, setCalMonth] = useState(() => {
|
||||
const d = new Date();
|
||||
return { year: d.getFullYear(), month: d.getMonth() };
|
||||
});
|
||||
|
||||
// Fetch all dates with summaries
|
||||
const fetchDates = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/summaries/dates", { credentials: "include" });
|
||||
if (!res.ok) throw new Error("Failed to fetch dates");
|
||||
const data = await res.json();
|
||||
setSummaryDates(new Set(data.dates));
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch summary dates:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch summary for selected date
|
||||
const fetchSummary = useCallback(async (date: string) => {
|
||||
setLoadingSummary(true);
|
||||
setSummary(null);
|
||||
try {
|
||||
const res = await fetch(`/api/summaries/${date}`, { credentials: "include" });
|
||||
if (res.status === 404) {
|
||||
setSummary(null);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error("Failed to fetch summary");
|
||||
const data = await res.json();
|
||||
setSummary(data);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch summary:", e);
|
||||
setSummary(null);
|
||||
} finally {
|
||||
setLoadingSummary(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDates();
|
||||
}, [fetchDates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate) {
|
||||
fetchSummary(selectedDate);
|
||||
}
|
||||
}, [selectedDate, fetchSummary]);
|
||||
|
||||
// Calendar data
|
||||
const daysInMonth = getDaysInMonth(calMonth.year, calMonth.month);
|
||||
const firstDay = getFirstDayOfMonth(calMonth.year, calMonth.month);
|
||||
const monthLabel = new Date(calMonth.year, calMonth.month, 1).toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const calDays = useMemo(() => {
|
||||
const days: (number | null)[] = [];
|
||||
for (let i = 0; i < firstDay; i++) days.push(null);
|
||||
for (let d = 1; d <= daysInMonth; d++) days.push(d);
|
||||
return days;
|
||||
}, [firstDay, daysInMonth]);
|
||||
|
||||
const prevMonth = () =>
|
||||
setCalMonth((p) => (p.month === 0 ? { year: p.year - 1, month: 11 } : { year: p.year, month: p.month - 1 }));
|
||||
const nextMonth = () =>
|
||||
setCalMonth((p) => (p.month === 11 ? { year: p.year + 1, month: 0 } : { year: p.year, month: p.month + 1 }));
|
||||
const goToday = () => {
|
||||
const d = new Date();
|
||||
setCalMonth({ year: d.getFullYear(), month: d.getMonth() });
|
||||
setSelectedDate(todayStr());
|
||||
};
|
||||
|
||||
const prevDay = () => {
|
||||
const dates = Array.from(summaryDates).sort().reverse();
|
||||
const idx = dates.indexOf(selectedDate);
|
||||
if (idx >= 0 && idx < dates.length - 1) setSelectedDate(dates[idx + 1]);
|
||||
else if (idx === -1) {
|
||||
const prev = dates.find((d) => d < selectedDate);
|
||||
if (prev) setSelectedDate(prev);
|
||||
else setSelectedDate(addDays(selectedDate, -1));
|
||||
} else {
|
||||
setSelectedDate(addDays(selectedDate, -1));
|
||||
}
|
||||
};
|
||||
|
||||
const nextDay = () => {
|
||||
const dates = Array.from(summaryDates).sort();
|
||||
const idx = dates.indexOf(selectedDate);
|
||||
if (idx >= 0 && idx < dates.length - 1) setSelectedDate(dates[idx + 1]);
|
||||
else if (idx === -1) {
|
||||
const next = dates.find((d) => d > selectedDate);
|
||||
if (next) setSelectedDate(next);
|
||||
else setSelectedDate(addDays(selectedDate, 1));
|
||||
} else {
|
||||
setSelectedDate(addDays(selectedDate, 1));
|
||||
}
|
||||
};
|
||||
|
||||
const today = todayStr();
|
||||
const statsEntries = summary
|
||||
? Object.entries(summary.stats).filter(([, v]) => v !== undefined && v > 0)
|
||||
: [];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center text-gray-400 dark:text-gray-500">
|
||||
Loading summaries...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Header */}
|
||||
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 sticky top-14 md:top-0 z-30">
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">📅 Daily Summaries</h1>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
{summaryDates.size} days logged
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={goToday}
|
||||
className="text-xs font-medium bg-amber-500 hover:bg-amber-600 text-white px-3 py-1.5 rounded-lg transition"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-6">
|
||||
{/* Calendar sidebar */}
|
||||
<div className="space-y-4">
|
||||
{/* Calendar widget */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400 transition"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">{monthLabel}</h3>
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400 transition"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Day headers */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((d) => (
|
||||
<div key={d} className="text-[10px] font-medium text-gray-400 dark:text-gray-500 text-center py-1">
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day cells */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{calDays.map((day, i) => {
|
||||
if (day === null) return <div key={`empty-${i}`} />;
|
||||
const dateStr = toDateStr(calMonth.year, calMonth.month, day);
|
||||
const hasSummary = summaryDates.has(dateStr);
|
||||
const isSelected = dateStr === selectedDate;
|
||||
const isToday = dateStr === today;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
onClick={() => setSelectedDate(dateStr)}
|
||||
className={`
|
||||
relative w-full aspect-square flex items-center justify-center rounded-lg text-xs font-medium transition
|
||||
${isSelected
|
||||
? "bg-amber-500 text-white"
|
||||
: isToday
|
||||
? "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400"
|
||||
: hasSummary
|
||||
? "bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||
: "text-gray-400 dark:text-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{day}
|
||||
{hasSummary && !isSelected && (
|
||||
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-amber-500" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent summaries list */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Recent Days</h3>
|
||||
<div className="space-y-1">
|
||||
{Array.from(summaryDates)
|
||||
.sort()
|
||||
.reverse()
|
||||
.slice(0, 10)
|
||||
.map((date) => (
|
||||
<button
|
||||
key={date}
|
||||
onClick={() => {
|
||||
setSelectedDate(date);
|
||||
const d = new Date(date + "T12:00:00Z");
|
||||
setCalMonth({ year: d.getUTCFullYear(), month: d.getUTCMonth() });
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition ${
|
||||
date === selectedDate
|
||||
? "bg-amber-500/20 text-amber-700 dark:text-amber-400 font-medium"
|
||||
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
{formatShort(date)}
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 ml-2">
|
||||
{new Date(date + "T12:00:00Z").toLocaleDateString("en-US", { weekday: "short" })}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{summaryDates.size === 0 && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 py-2">No summaries yet</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary content */}
|
||||
<div>
|
||||
{/* Date navigation */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={prevDay}
|
||||
className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Prev
|
||||
</button>
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatDateDisplay(selectedDate)}
|
||||
</h2>
|
||||
<button
|
||||
onClick={nextDay}
|
||||
className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition"
|
||||
>
|
||||
Next
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingSummary ? (
|
||||
<div className="flex items-center justify-center py-20 text-gray-400 dark:text-gray-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-amber-400 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
|
||||
<span className="w-2 h-2 bg-amber-400 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
|
||||
<span className="w-2 h-2 bg-amber-400 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
|
||||
</div>
|
||||
</div>
|
||||
) : summary ? (
|
||||
<div className="space-y-4">
|
||||
{/* Stats bar */}
|
||||
{statsEntries.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{statsEntries.map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<span className="text-lg">{STAT_ICONS[key] || "📊"}</span>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-gray-900 dark:text-gray-100">{value}</p>
|
||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 uppercase tracking-wider">
|
||||
{STAT_LABELS[key] || key}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Highlights */}
|
||||
{summary.highlights && summary.highlights.length > 0 && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-xl border border-amber-200 dark:border-amber-800/50 p-4">
|
||||
<h3 className="text-sm font-semibold text-amber-800 dark:text-amber-400 mb-2">
|
||||
⚡ Key Accomplishments
|
||||
</h3>
|
||||
<ul className="space-y-1.5">
|
||||
{summary.highlights.map((h, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-amber-900 dark:text-amber-200">
|
||||
<span className="text-amber-500 mt-0.5">•</span>
|
||||
<span>{h.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full markdown content */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-6">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none prose-headings:text-gray-900 dark:prose-headings:text-gray-100 prose-a:text-amber-600 dark:prose-a:text-amber-400 prose-code:text-amber-700 dark:prose-code:text-amber-300 prose-code:bg-gray-100 dark:prose-code:bg-gray-800 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-pre:bg-gray-100 dark:prose-pre:bg-gray-800">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{summary.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Empty state */
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-12 text-center">
|
||||
<div className="text-4xl mb-3">📭</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
No summary for this day
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||
{selectedDate === today
|
||||
? "Today's summary hasn't been created yet. Check back later!"
|
||||
: "No work was logged for this date."}
|
||||
</p>
|
||||
{summaryDates.size > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const nearest = Array.from(summaryDates).sort().reverse()[0];
|
||||
if (nearest) {
|
||||
setSelectedDate(nearest);
|
||||
const d = new Date(nearest + "T12:00:00Z");
|
||||
setCalMonth({ year: d.getUTCFullYear(), month: d.getUTCMonth() });
|
||||
}
|
||||
}}
|
||||
className="mt-4 text-sm text-amber-600 dark:text-amber-400 hover:underline"
|
||||
>
|
||||
Jump to latest summary →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import remarkGfm from "remark-gfm";
|
||||
import type { Task, TaskStatus, Project } from "../lib/types";
|
||||
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { TaskComments } from "../components/TaskComments";
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
critical: "bg-red-500 text-white",
|
||||
@@ -598,6 +599,8 @@ export function TaskPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Comments / Discussion */}
|
||||
<TaskComments taskId={task.id} />
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
|
||||
511
frontend/src/pages/TodosPage.tsx
Normal file
511
frontend/src/pages/TodosPage.tsx
Normal file
@@ -0,0 +1,511 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { Todo, TodoPriority } from "../lib/types";
|
||||
import {
|
||||
fetchTodos,
|
||||
fetchTodoCategories,
|
||||
createTodo,
|
||||
updateTodo,
|
||||
toggleTodo,
|
||||
deleteTodo,
|
||||
} from "../lib/api";
|
||||
|
||||
const PRIORITY_COLORS: Record<TodoPriority, string> = {
|
||||
high: "text-red-500",
|
||||
medium: "text-amber-500",
|
||||
low: "text-blue-400",
|
||||
none: "text-gray-400 dark:text-gray-600",
|
||||
};
|
||||
|
||||
const PRIORITY_BG: Record<TodoPriority, string> = {
|
||||
high: "bg-red-500/10 border-red-500/30 text-red-400",
|
||||
medium: "bg-amber-500/10 border-amber-500/30 text-amber-400",
|
||||
low: "bg-blue-500/10 border-blue-500/30 text-blue-400",
|
||||
none: "bg-gray-500/10 border-gray-500/30 text-gray-400",
|
||||
};
|
||||
|
||||
const PRIORITY_LABELS: Record<TodoPriority, string> = {
|
||||
high: "High",
|
||||
medium: "Medium",
|
||||
low: "Low",
|
||||
none: "None",
|
||||
};
|
||||
|
||||
function formatDueDate(dateStr: string | null): string {
|
||||
if (!dateStr) return "";
|
||||
const date = new Date(dateStr);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const dateOnly = new Date(date);
|
||||
dateOnly.setHours(0, 0, 0, 0);
|
||||
|
||||
if (dateOnly.getTime() === today.getTime()) return "Today";
|
||||
if (dateOnly.getTime() === tomorrow.getTime()) return "Tomorrow";
|
||||
|
||||
const diff = dateOnly.getTime() - today.getTime();
|
||||
const days = Math.round(diff / (1000 * 60 * 60 * 24));
|
||||
if (days < 0) return `${Math.abs(days)}d overdue`;
|
||||
if (days < 7) return date.toLocaleDateString("en-US", { weekday: "short" });
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function isDueOverdue(dateStr: string | null): boolean {
|
||||
if (!dateStr) return false;
|
||||
const date = new Date(dateStr);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return date < today;
|
||||
}
|
||||
|
||||
function isDueToday(dateStr: string | null): boolean {
|
||||
if (!dateStr) return false;
|
||||
const date = new Date(dateStr);
|
||||
const today = new Date();
|
||||
return date.toDateString() === today.toDateString();
|
||||
}
|
||||
|
||||
// ─── Todo Item Component ───
|
||||
|
||||
function TodoItem({
|
||||
todo,
|
||||
onToggle,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: {
|
||||
todo: Todo;
|
||||
onToggle: (id: string) => void;
|
||||
onUpdate: (id: string, updates: { title?: string }) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState(todo.title);
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) inputRef.current?.focus();
|
||||
}, [editing]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (editTitle.trim() && editTitle !== todo.title) {
|
||||
onUpdate(todo.id, { title: editTitle.trim() });
|
||||
}
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const overdue = isDueOverdue(todo.dueDate) && !todo.isCompleted;
|
||||
const dueToday = isDueToday(todo.dueDate) && !todo.isCompleted;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group flex items-start gap-3 px-3 py-2.5 rounded-lg transition hover:bg-gray-100 dark:hover:bg-gray-800/50 ${
|
||||
todo.isCompleted ? "opacity-50" : ""
|
||||
}`}
|
||||
onMouseEnter={() => setShowActions(true)}
|
||||
onMouseLeave={() => setShowActions(false)}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<button
|
||||
onClick={() => onToggle(todo.id)}
|
||||
className={`mt-0.5 w-5 h-5 rounded-full border-2 flex-shrink-0 flex items-center justify-center transition-all ${
|
||||
todo.isCompleted
|
||||
? "bg-green-500 border-green-500 text-white"
|
||||
: `border-gray-300 dark:border-gray-600 hover:border-green-400 ${PRIORITY_COLORS[todo.priority]}`
|
||||
}`}
|
||||
style={
|
||||
!todo.isCompleted && todo.priority !== "none"
|
||||
? {
|
||||
borderColor:
|
||||
todo.priority === "high"
|
||||
? "#ef4444"
|
||||
: todo.priority === "medium"
|
||||
? "#f59e0b"
|
||||
: "#60a5fa",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{todo.isCompleted && (
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSave();
|
||||
if (e.key === "Escape") {
|
||||
setEditTitle(todo.title);
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
className="w-full bg-transparent border-b border-amber-400 dark:border-amber-500 text-gray-900 dark:text-gray-100 text-sm focus:outline-none py-0.5"
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
onClick={() => !todo.isCompleted && setEditing(true)}
|
||||
className={`text-sm cursor-pointer ${
|
||||
todo.isCompleted
|
||||
? "line-through text-gray-400 dark:text-gray-500"
|
||||
: "text-gray-900 dark:text-gray-100"
|
||||
}`}
|
||||
>
|
||||
{todo.title}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Meta row */}
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{todo.category && (
|
||||
<span className="text-[11px] px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400">
|
||||
{todo.category}
|
||||
</span>
|
||||
)}
|
||||
{todo.dueDate && (
|
||||
<span
|
||||
className={`text-[11px] ${
|
||||
overdue
|
||||
? "text-red-500 font-medium"
|
||||
: dueToday
|
||||
? "text-amber-500 font-medium"
|
||||
: "text-gray-400 dark:text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{formatDueDate(todo.dueDate)}
|
||||
</span>
|
||||
)}
|
||||
{todo.priority !== "none" && (
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded border ${PRIORITY_BG[todo.priority]}`}
|
||||
>
|
||||
{PRIORITY_LABELS[todo.priority]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div
|
||||
className={`flex items-center gap-1 transition-opacity ${
|
||||
showActions ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => onDelete(todo.id)}
|
||||
className="p-1 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition"
|
||||
title="Delete"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Add Todo Form ───
|
||||
|
||||
function AddTodoForm({
|
||||
onAdd,
|
||||
categories,
|
||||
}: {
|
||||
onAdd: (todo: { title: string; priority?: TodoPriority; category?: string; dueDate?: string }) => void;
|
||||
categories: string[];
|
||||
}) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [showExpanded, setShowExpanded] = useState(false);
|
||||
const [priority, setPriority] = useState<TodoPriority>("none");
|
||||
const [category, setCategory] = useState("");
|
||||
const [newCategory, setNewCategory] = useState("");
|
||||
const [dueDate, setDueDate] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
|
||||
const cat = newCategory.trim() || category || undefined;
|
||||
onAdd({
|
||||
title: title.trim(),
|
||||
priority: priority !== "none" ? priority : undefined,
|
||||
category: cat,
|
||||
dueDate: dueDate || undefined,
|
||||
});
|
||||
|
||||
setTitle("");
|
||||
setPriority("none");
|
||||
setCategory("");
|
||||
setNewCategory("");
|
||||
setDueDate("");
|
||||
setShowExpanded(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Add a todo..."
|
||||
className="w-full px-3 py-2.5 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-gray-100 text-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-amber-400/50 focus:border-amber-400"
|
||||
onFocus={() => setShowExpanded(true)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!title.trim()}
|
||||
className="px-4 py-2.5 rounded-lg bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50 disabled:cursor-not-allowed transition"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showExpanded && (
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap animate-slide-up">
|
||||
{/* Priority */}
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as TodoPriority)}
|
||||
className="text-xs px-2 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
>
|
||||
<option value="none">No priority</option>
|
||||
<option value="high">🔴 High</option>
|
||||
<option value="medium">🟡 Medium</option>
|
||||
<option value="low">🔵 Low</option>
|
||||
</select>
|
||||
|
||||
{/* Category */}
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => {
|
||||
setCategory(e.target.value);
|
||||
if (e.target.value) setNewCategory("");
|
||||
}}
|
||||
className="text-xs px-2 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
>
|
||||
<option value="">No category</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
value={newCategory}
|
||||
onChange={(e) => {
|
||||
setNewCategory(e.target.value);
|
||||
if (e.target.value) setCategory("");
|
||||
}}
|
||||
placeholder="New category..."
|
||||
className="text-xs px-2 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-amber-400 w-32"
|
||||
/>
|
||||
|
||||
{/* Due date */}
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
className="text-xs px-2 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowExpanded(false)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 px-1"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Todos Page ───
|
||||
|
||||
type FilterTab = "all" | "active" | "completed";
|
||||
|
||||
export function TodosPage() {
|
||||
const [todos, setTodos] = useState<Todo[]>([]);
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<FilterTab>("active");
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>("");
|
||||
|
||||
const loadTodos = useCallback(async () => {
|
||||
try {
|
||||
const params: { completed?: string; category?: string } = {};
|
||||
if (filter === "active") params.completed = "false";
|
||||
if (filter === "completed") params.completed = "true";
|
||||
if (categoryFilter) params.category = categoryFilter;
|
||||
|
||||
const [data, cats] = await Promise.all([
|
||||
fetchTodos(params),
|
||||
fetchTodoCategories(),
|
||||
]);
|
||||
setTodos(data);
|
||||
setCategories(cats);
|
||||
} catch (e) {
|
||||
console.error("Failed to load todos:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filter, categoryFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTodos();
|
||||
}, [loadTodos]);
|
||||
|
||||
const handleAdd = async (todo: { title: string; priority?: TodoPriority; category?: string; dueDate?: string }) => {
|
||||
try {
|
||||
await createTodo(todo);
|
||||
loadTodos();
|
||||
} catch (e) {
|
||||
console.error("Failed to create todo:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (id: string) => {
|
||||
try {
|
||||
// Optimistic update
|
||||
setTodos((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === id ? { ...t, isCompleted: !t.isCompleted } : t
|
||||
)
|
||||
);
|
||||
await toggleTodo(id);
|
||||
// Reload after a short delay to let the animation play
|
||||
setTimeout(loadTodos, 300);
|
||||
} catch (e) {
|
||||
console.error("Failed to toggle todo:", e);
|
||||
loadTodos();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: string, updates: { title?: string; description?: string; priority?: TodoPriority; category?: string | null; dueDate?: string | null; isCompleted?: boolean }) => {
|
||||
try {
|
||||
await updateTodo(id, updates);
|
||||
loadTodos();
|
||||
} catch (e) {
|
||||
console.error("Failed to update todo:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
setTodos((prev) => prev.filter((t) => t.id !== id));
|
||||
await deleteTodo(id);
|
||||
} catch (e) {
|
||||
console.error("Failed to delete todo:", e);
|
||||
loadTodos();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-6 md:py-10">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<span>✅</span> Todos
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Personal checklist — quick todos and reminders
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Add form */}
|
||||
<AddTodoForm onAdd={handleAdd} categories={categories} />
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex items-center gap-1 mb-4 border-b border-gray-200 dark:border-gray-800">
|
||||
{(["active", "all", "completed"] as FilterTab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setFilter(tab)}
|
||||
className={`px-3 py-2 text-sm font-medium border-b-2 transition ${
|
||||
filter === tab
|
||||
? "border-amber-500 text-amber-600 dark:text-amber-400"
|
||||
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{tab === "active" ? "Active" : tab === "completed" ? "Completed" : "All"}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Category filter */}
|
||||
{categories.length > 0 && (
|
||||
<div className="ml-auto">
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="text-xs px-2 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All categories</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Todo list */}
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-gray-400">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="w-2 h-2 bg-amber-400 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
|
||||
<span className="w-2 h-2 bg-amber-400 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
|
||||
<span className="w-2 h-2 bg-amber-400 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
|
||||
</div>
|
||||
</div>
|
||||
) : todos.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-gray-400 dark:text-gray-500 text-lg">
|
||||
{filter === "completed" ? "No completed todos yet" : "All clear! 🎉"}
|
||||
</p>
|
||||
<p className="text-gray-400 dark:text-gray-600 text-sm mt-1">
|
||||
{filter === "active" ? "Add a todo above to get started" : ""}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{todos.map((todo) => (
|
||||
<TodoItem
|
||||
key={todo.id}
|
||||
todo={todo}
|
||||
onToggle={handleToggle}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer stats */}
|
||||
{!loading && todos.length > 0 && (
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-800 text-center">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-600">
|
||||
{todos.length} {todos.length === 1 ? "todo" : "todos"} shown
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user