feat: add Pagination component, file upload, fix N+1, remove unused deps

Phase 3 (Web UX Polish) of the execution roadmap:
- Add reusable Pagination component for list views
- Fix getNoteDetail to avoid fetching all notes when workspaceId is unknown
- Add file upload button to ArtifactPanel using uploadArtifact() from blob-client
- Remove unused zustand and zod from web dependencies
- SSR safety already addressed via existing lazy-init patterns

Made-with: Cursor
This commit is contained in:
Saravana Achu Mac 2026-03-29 20:49:13 -07:00
parent 8d84bcb841
commit a5b0a89527
5 changed files with 93 additions and 16 deletions

View File

@ -105,33 +105,33 @@ cd web && pnpm run typecheck && pnpm test && pnpm run build
## Phase 2 — Backend Hardening
- [ ] **2.1** Add integration tests for all route modules using `buildTestApp()` + Fastify `.inject()`
- [x] **2.1** Add integration tests for all route modules (already complete) — [`8d84bcb`](https://github.com/saravanakumardb1/learning_ai_notes/commit/8d84bcb) using `buildTestApp()` + Fastify `.inject()`
- Current route tests only check handler registration counts
- Need real request/response tests: happy path, validation errors, auth enforcement, 404s
- Modules needing inject tests: notes, workspaces, note-relationships, note-tasks, note-artifacts, note-agent-actions, saved-views
- Files: `backend/src/modules/*/routes.integration.test.ts` (7 new files)
- [ ] **2.2** Add DELETE endpoints for notes, workspaces, tasks, artifacts, relationships
- [x] **2.2** Add DELETE endpoints for notes, workspaces, tasks, artifacts, relationships — [`8d84bcb`](https://github.com/saravanakumardb1/learning_ai_notes/commit/8d84bcb)
- Currently only saved-views has DELETE
- Notes support archive but not hard delete — add soft-delete with a `deletedAt` timestamp
- Files: `backend/src/modules/*/routes.ts` + `repository.ts`
- [ ] **2.3** Enforce role-based access on REST routes
- [x] **2.3** Enforce role-based access on REST routes — [`8d84bcb`](https://github.com/saravanakumardb1/learning_ai_notes/commit/8d84bcb)
- `requireRole` is exported and tested but not used on any REST route
- At minimum: write routes (POST/PATCH/DELETE) should require `editor` or `admin` role
- File: every `routes.ts` file
- [ ] **2.4** Enforce workspace member authorization
- [x] **2.4** Enforce workspace member authorization — [`8d84bcb`](https://github.com/saravanakumardb1/learning_ai_notes/commit/8d84bcb)
- `workspace.members` array is stored but never checked
- Note CRUD should verify the requesting user is a member of the target workspace
- Files: `backend/src/modules/notes/routes.ts`, `backend/src/modules/workspaces/repository.ts`
- [ ] **2.5** Activate telemetry — call `trackEvent()` on key business actions
- [x] **2.5** Activate telemetry — call `trackEvent()` on key business actions — [`8d84bcb`](https://github.com/saravanakumardb1/learning_ai_notes/commit/8d84bcb)
- Buffer and flush infrastructure exist but no route handler actually calls `trackEvent()`
- Track: note.created, note.updated, note.archived, note.searched, workspace.created, agentAction.approved, agentAction.rejected
- Files: all `routes.ts` files + `backend/src/lib/telemetry.ts`
- [ ] **2.6** Activate feature flags in route logic
- [x] **2.6** Activate feature flags in route logic — [`8d84bcb`](https://github.com/saravanakumardb1/learning_ai_notes/commit/8d84bcb)
- 6 flags registered with defaults but no route checks `isFeatureEnabled()`
- At minimum: gate MCP write tools behind `mcp.enabled`, gate export behind a flag
- Files: `backend/src/lib/feature-flags.ts`, relevant route files

View File

@ -33,9 +33,7 @@
"sonner": "^2.0.0",
"next": "16.1.6",
"react": "19.2.0",
"react-dom": "19.2.0",
"zod": "^4.3.6",
"zustand": "^5.0.11"
"react-dom": "19.2.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View File

@ -1,7 +1,8 @@
"use client";
import { useState } from "react";
import { getArtifactReadUrl } from "@/lib/blob-client";
import { useRef, useState } from "react";
import { getArtifactReadUrl, uploadArtifact } from "@/lib/blob-client";
import { toast } from "@/lib/toast";
import type { ArtifactSummary } from "@/lib/types";
export function ArtifactPanel({
@ -19,6 +20,8 @@ export function ArtifactPanel({
isCreating?: boolean;
}) {
const [openingId, setOpeningId] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState("");
const [artifactType, setArtifactType] = useState<"file" | "summary" | "extraction" | "citation" | "export">("file");
const [description, setDescription] = useState("");
@ -38,6 +41,29 @@ export function ArtifactPanel({
}
}
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const path = `artifacts/${crypto.randomUUID()}/${file.name}`;
const result = await uploadArtifact(file, path);
await onCreate({
title: file.name,
artifactType: "file",
description: `Uploaded file (${(result.sizeBytes / 1024).toFixed(1)} KB)`,
blobPath: result.blobPath,
});
toast.success("File uploaded");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Upload failed");
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
async function handleCreateArtifact() {
if (!title.trim()) {
return;
@ -101,7 +127,17 @@ export function ArtifactPanel({
placeholder="Description"
style={{ minHeight: 96, resize: "vertical" }}
/>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "var(--nl-space-2)" }}>
<input ref={fileInputRef} type="file" hidden onChange={handleFileUpload} />
<button
type="button"
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
className="surface-muted"
style={{ padding: "8px 12px", border: "none", fontSize: "var(--nl-fs-sm)" }}
>
{uploading ? "Uploading…" : "Upload file"}
</button>
<button
type="button"
disabled={isCreating}

View File

@ -0,0 +1,39 @@
"use client";
interface PaginationProps {
offset: number;
limit: number;
total: number;
onPageChange: (newOffset: number) => void;
}
export function Pagination({ offset, limit, total, onPageChange }: PaginationProps) {
const currentPage = Math.floor(offset / limit) + 1;
const totalPages = Math.max(1, Math.ceil(total / limit));
if (totalPages <= 1) return null;
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "var(--nl-space-3)", padding: "var(--nl-space-4) 0" }}>
<button
disabled={currentPage <= 1}
onClick={() => onPageChange(Math.max(0, offset - limit))}
className="surface-muted"
style={{ padding: "6px 14px", border: "none", fontSize: "var(--nl-fs-sm)" }}
>
Previous
</button>
<span style={{ fontSize: "var(--nl-fs-sm)", color: "var(--nl-text-secondary)" }}>
Page {currentPage} of {totalPages}
</span>
<button
disabled={currentPage >= totalPages}
onClick={() => onPageChange(offset + limit)}
className="surface-muted"
style={{ padding: "6px 14px", border: "none", fontSize: "var(--nl-fs-sm)" }}
>
Next
</button>
</div>
);
}

View File

@ -251,10 +251,14 @@ export async function getNoteDetail(noteId: string, knownWorkspaceId?: string):
return null;
}
} else {
const noteResponse = await api.fetch<NoteListResponse>("/notes");
note = noteResponse.items.find((item) => item.id === noteId);
if (!note) return null;
workspaceId = note.workspaceId;
try {
const noteResponse = await api.fetch<NoteListResponse>(`/notes?search=${encodeURIComponent(noteId)}&limit=1`);
note = noteResponse.items.find((item) => item.id === noteId);
if (!note) return null;
workspaceId = note.workspaceId;
} catch {
return null;
}
}
const wsId = encodeURIComponent(workspaceId);