mirror of
https://github.com/willchen96/mike.git
synced 2026-07-24 23:41:04 +02:00
feat: workflow, UI and document support updates
This commit is contained in:
parent
a5fe6d6e04
commit
82dcaefc43
139 changed files with 12554 additions and 2233 deletions
|
|
@ -22,10 +22,9 @@ Thanks for helping improve Mike. Please keep contributions small, focused, and e
|
|||
|
||||
## System Workflows
|
||||
|
||||
System workflows live in `workflows/`. Update `metadata.json` for structured
|
||||
metadata like author, language, version, practice area, or jurisdictions,
|
||||
update `SKILL.md` for workflow instructions, and use `table-config.json` for
|
||||
tabular review columns.
|
||||
System workflows live in `mike-workflows/system/`. Put structured metadata in
|
||||
the YAML frontmatter at the top of `SKILL.md`, put workflow instructions in the
|
||||
body of `SKILL.md`, and use `table-config.yaml` for tabular review columns.
|
||||
|
||||
After changing system workflows, regenerate the app files:
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
"multer": "^1.4.5-lts.2",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"resend": "^4.5.1",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"zod": "^3.25.76",
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -769,6 +770,8 @@
|
|||
|
||||
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
||||
|
||||
"xlsx": ["xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", { "bin": { "xlsx": "./bin/xlsx.njs" } }],
|
||||
|
||||
"xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="],
|
||||
|
||||
"xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": "bin/cli.js" }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="],
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ create table if not exists public.workflow_open_source_submissions (
|
|||
submitted_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
reviewed_at timestamptz,
|
||||
reviewed_by_user_id text,
|
||||
review_notes text,
|
||||
constraint workflow_open_source_submissions_status_check
|
||||
check (status in ('pending', 'approved', 'rejected')),
|
||||
|
|
|
|||
41
backend/migrations/20260703_01_user_profile_email.sql
Normal file
41
backend/migrations/20260703_01_user_profile_email.sql
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
-- Mirror auth.users.email into user_profiles so backend sharing checks can
|
||||
-- resolve one email without scanning Supabase Auth users.
|
||||
|
||||
alter table public.user_profiles
|
||||
add column if not exists email text;
|
||||
|
||||
update public.user_profiles up
|
||||
set email = lower(au.email)
|
||||
from auth.users au
|
||||
where up.user_id = au.id
|
||||
and au.email is not null
|
||||
and (
|
||||
up.email is null
|
||||
or up.email <> lower(au.email)
|
||||
);
|
||||
|
||||
create unique index if not exists user_profiles_email_lower_unique
|
||||
on public.user_profiles (lower(email))
|
||||
where email is not null and btrim(email) <> '';
|
||||
|
||||
create index if not exists idx_user_profiles_email
|
||||
on public.user_profiles(email);
|
||||
|
||||
create or replace function public.handle_new_user()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
begin
|
||||
insert into public.user_profiles (user_id, email)
|
||||
values (new.id, lower(new.email))
|
||||
on conflict (user_id) do update
|
||||
set email = excluded.email,
|
||||
updated_at = now();
|
||||
return new;
|
||||
exception when others then
|
||||
-- Never block signup if the profile insert fails.
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
84
backend/migrations/20260703_02_project_practice.sql
Normal file
84
backend/migrations/20260703_02_project_practice.sql
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
-- Add optional practice metadata to projects and expose it in the overview RPC.
|
||||
|
||||
alter table public.projects
|
||||
add column if not exists practice text;
|
||||
|
||||
drop function if exists public.get_projects_overview(text, text);
|
||||
|
||||
create or replace function public.get_projects_overview(
|
||||
p_user_id text,
|
||||
p_user_email text default null
|
||||
)
|
||||
returns table (
|
||||
id uuid,
|
||||
user_id text,
|
||||
name text,
|
||||
cm_number text,
|
||||
practice text,
|
||||
shared_with jsonb,
|
||||
created_at timestamptz,
|
||||
updated_at timestamptz,
|
||||
is_owner boolean,
|
||||
owner_display_name text,
|
||||
owner_email text,
|
||||
document_count integer,
|
||||
chat_count integer,
|
||||
review_count integer
|
||||
)
|
||||
language sql
|
||||
stable
|
||||
as $$
|
||||
with visible_projects as (
|
||||
select p.*
|
||||
from public.projects p
|
||||
where p.user_id = p_user_id
|
||||
or (
|
||||
coalesce(p_user_email, '') <> ''
|
||||
and p.user_id <> p_user_id
|
||||
and p.shared_with @> jsonb_build_array(p_user_email)
|
||||
)
|
||||
),
|
||||
document_counts as (
|
||||
select d.project_id, count(*)::integer as document_count
|
||||
from public.documents d
|
||||
where d.project_id in (select vp.id from visible_projects vp)
|
||||
group by d.project_id
|
||||
),
|
||||
chat_counts as (
|
||||
select c.project_id, count(*)::integer as chat_count
|
||||
from public.chats c
|
||||
where c.project_id in (select vp.id from visible_projects vp)
|
||||
group by c.project_id
|
||||
),
|
||||
review_counts as (
|
||||
select tr.project_id, count(*)::integer as review_count
|
||||
from public.tabular_reviews tr
|
||||
where tr.project_id in (select vp.id from visible_projects vp)
|
||||
group by tr.project_id
|
||||
)
|
||||
select
|
||||
vp.id,
|
||||
vp.user_id,
|
||||
vp.name,
|
||||
vp.cm_number,
|
||||
vp.practice,
|
||||
vp.shared_with,
|
||||
vp.created_at,
|
||||
vp.updated_at,
|
||||
vp.user_id = p_user_id as is_owner,
|
||||
nullif(trim(up.display_name), '') as owner_display_name,
|
||||
null::text as owner_email,
|
||||
coalesce(dc.document_count, 0) as document_count,
|
||||
coalesce(cc.chat_count, 0) as chat_count,
|
||||
coalesce(rc.review_count, 0) as review_count
|
||||
from visible_projects vp
|
||||
left join public.user_profiles up
|
||||
on up.user_id::text = vp.user_id
|
||||
left join document_counts dc
|
||||
on dc.project_id = vp.id
|
||||
left join chat_counts cc
|
||||
on cc.project_id = vp.id
|
||||
left join review_counts rc
|
||||
on rc.project_id = vp.id
|
||||
order by vp.created_at desc;
|
||||
$$;
|
||||
19
backend/migrations/20260704_01_chat_message_citations.sql
Normal file
19
backend/migrations/20260704_01_chat_message_citations.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
do $$
|
||||
begin
|
||||
if exists (
|
||||
select 1
|
||||
from information_schema.columns
|
||||
where table_schema = 'public'
|
||||
and table_name = 'chat_messages'
|
||||
and column_name = 'annotations'
|
||||
) and not exists (
|
||||
select 1
|
||||
from information_schema.columns
|
||||
where table_schema = 'public'
|
||||
and table_name = 'chat_messages'
|
||||
and column_name = 'citations'
|
||||
) then
|
||||
alter table public.chat_messages
|
||||
rename column annotations to citations;
|
||||
end if;
|
||||
end $$;
|
||||
|
|
@ -4,7 +4,6 @@
|
|||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"prebuild": "node ../scripts/build-workflows.js",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -539,7 +539,7 @@ export async function buildWorkflowStore(
|
|||
|
||||
// Seed system workflows first.
|
||||
for (const wf of SYSTEM_ASSISTANT_WORKFLOWS) {
|
||||
store.set(wf.id, { title: wf.title, prompt_md: wf.prompt_md });
|
||||
store.set(wf.id, { title: wf.title, skill_md: wf.skill_md });
|
||||
}
|
||||
|
||||
// Then overlay user-owned assistant workflows.
|
||||
|
|
@ -550,7 +550,7 @@ export async function buildWorkflowStore(
|
|||
.eq("type", "assistant");
|
||||
for (const wf of workflows ?? []) {
|
||||
if (wf.prompt_md) {
|
||||
store.set(wf.id, { title: wf.title, prompt_md: wf.prompt_md });
|
||||
store.set(wf.id, { title: wf.title, skill_md: wf.prompt_md });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -573,7 +573,7 @@ export async function buildWorkflowStore(
|
|||
if (wf.prompt_md) {
|
||||
store.set(wf.id, {
|
||||
title: wf.title,
|
||||
prompt_md: wf.prompt_md,
|
||||
skill_md: wf.prompt_md,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ Citation rules:
|
|||
- Keep quotes short, ideally 25 words or fewer, and tightly matched to the claim.
|
||||
- "page" means the sequential [Page N] marker in the provided text, not printed page numbers inside the document. Non-spreadsheet unpaginated files may have no [Page N] markers; omit "page" (or use 1) when none is present.
|
||||
- For spreadsheet sources (content shown as "## Sheet: <name>" markdown tables with a "Row" column and column-letter headers), cite by cell instead of page: set "sheet" to the sheet name and "cell" to the A1 address or range you are quoting (e.g. "B7" or "B7:C9", combining the column-letter header with the "Row" number). Put the plain cell value in "quote" with no "Row"/column-letter labels or "|" separators. Omit "page" for spreadsheet citations.
|
||||
- A cell tagged "⟨merged A1:C1⟩" spans that whole range: its value belongs to the anchor cell and the other covered cells are shown blank. When citing anything in a merged range, set "cell" to the full range from the tag (e.g. "A1:C1"), not a covered cell like "B1". Do not include the "⟨merged ...⟩" tag text in "quote".
|
||||
- For a continuous quote crossing two pages, set "page" to "N-M" and include [[PAGE_BREAK]] at the page break. Otherwise, use separate quote objects.
|
||||
- For legacy compatibility, you may also include top-level "page" and "quote" matching the first quote.
|
||||
- Omit the <CITATIONS> block when there are no citations.
|
||||
|
|
|
|||
|
|
@ -777,7 +777,7 @@ export async function runToolCalls(
|
|||
toolResults.push({
|
||||
role: "tool",
|
||||
tool_call_id: tc.id,
|
||||
content: wf ? wf.prompt_md : `Workflow '${wfId}' not found.`,
|
||||
content: wf ? wf.skill_md : `Workflow '${wfId}' not found.`,
|
||||
});
|
||||
} else if (tc.function.name === "read_table_cells" && tabularStore) {
|
||||
const colIndices = args.col_indices as number[] | undefined;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export type DocStore = Map<
|
|||
{ storage_path: string; file_type: string; filename: string }
|
||||
>;
|
||||
|
||||
export type WorkflowStore = Map<string, { title: string; prompt_md: string }>;
|
||||
export type WorkflowStore = Map<string, { title: string; skill_md: string }>;
|
||||
|
||||
export type DocIndex = Record<
|
||||
string,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,16 @@ function renderSheet(sheetName: string, ws: XLSX.WorkSheet): string | null {
|
|||
if (!ref) return null;
|
||||
const range = XLSX.utils.decode_range(ref);
|
||||
|
||||
// Map each merged range's top-left (anchor) address to its encoded range so we
|
||||
// can tag the anchor inline (e.g. `Amount ⟨merged B2:C2⟩`). The covered cells
|
||||
// stay blank, so the model never reads a covered address (e.g. B1 inside
|
||||
// A1:C1) as its own value; the tag tells it the anchor spans that range, and
|
||||
// to cite the whole range for anything in it.
|
||||
const mergeAnchors = new Map<string, string>();
|
||||
for (const m of ws["!merges"] ?? []) {
|
||||
mergeAnchors.set(XLSX.utils.encode_cell(m.s), XLSX.utils.encode_range(m));
|
||||
}
|
||||
|
||||
// Build a trimmed grid: capture formatted text for every cell in the used
|
||||
// range, then drop trailing empty columns and fully empty rows so we don't
|
||||
// emit oceans of blank cells.
|
||||
|
|
@ -44,7 +54,13 @@ function renderSheet(sheetName: string, ws: XLSX.WorkSheet): string | null {
|
|||
let rowHasContent = false;
|
||||
for (let c = range.s.c; c <= range.e.c; c++) {
|
||||
const addr = XLSX.utils.encode_cell({ r, c });
|
||||
const text = sanitizeCellText(cellDisplayText(ws[addr]));
|
||||
let text = sanitizeCellText(cellDisplayText(ws[addr]));
|
||||
const mergeRange = mergeAnchors.get(addr);
|
||||
if (mergeRange) {
|
||||
text = text
|
||||
? `${text} ⟨merged ${mergeRange}⟩`
|
||||
: `⟨merged ${mergeRange}⟩`;
|
||||
}
|
||||
cells[c - range.s.c] = text;
|
||||
if (text) {
|
||||
rowHasContent = true;
|
||||
|
|
@ -72,13 +88,6 @@ function renderSheet(sheetName: string, ws: XLSX.WorkSheet): string | null {
|
|||
|
||||
const lines = [`## Sheet: ${sheetName}`, "", headerRow, separator, ...bodyRows];
|
||||
|
||||
// Note merged ranges once so the model understands spanned headers/labels.
|
||||
const merges = ws["!merges"];
|
||||
if (merges && merges.length > 0) {
|
||||
const encoded = merges.map((m) => XLSX.utils.encode_range(m));
|
||||
lines.push("", `Merged ranges: ${encoded.join(", ")}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
113
backend/src/lib/userLookup.ts
Normal file
113
backend/src/lib/userLookup.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
type Db = SupabaseClient<any, "public", any>;
|
||||
|
||||
export type ProfileUserInfo = {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
};
|
||||
|
||||
export function normalizeEmail(value: unknown) {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
export function normalizeDisplayName(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
export async function loadProfileUsersByEmail(db: Db) {
|
||||
const { data, error } = await db
|
||||
.from("user_profiles")
|
||||
.select("user_id, email, display_name")
|
||||
.not("email", "is", null);
|
||||
if (error) throw error;
|
||||
|
||||
const userByEmail = new Map<string, ProfileUserInfo>();
|
||||
const userById = new Map<string, ProfileUserInfo>();
|
||||
for (const row of data ?? []) {
|
||||
const email = normalizeEmail(row.email);
|
||||
if (!email) continue;
|
||||
const info = {
|
||||
id: row.user_id as string,
|
||||
email,
|
||||
display_name: normalizeDisplayName(row.display_name),
|
||||
};
|
||||
userByEmail.set(email, info);
|
||||
userById.set(info.id, info);
|
||||
}
|
||||
|
||||
return { userByEmail, userById };
|
||||
}
|
||||
|
||||
export async function findProfileUserByEmail(db: Db, email: string) {
|
||||
const normalized = normalizeEmail(email);
|
||||
if (!normalized) return null;
|
||||
|
||||
const { data, error } = await db
|
||||
.from("user_profiles")
|
||||
.select("user_id, email, display_name")
|
||||
.eq("email", normalized)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
if (!data) return null;
|
||||
|
||||
return {
|
||||
id: data.user_id as string,
|
||||
email: normalized,
|
||||
display_name: normalizeDisplayName(data.display_name),
|
||||
};
|
||||
}
|
||||
|
||||
export async function findMissingUserEmails(db: Db, emails: string[]) {
|
||||
const normalizedEmails = [...new Set(emails.map(normalizeEmail).filter(Boolean))];
|
||||
if (normalizedEmails.length === 0) return [];
|
||||
|
||||
const { data, error } = await db
|
||||
.from("user_profiles")
|
||||
.select("email")
|
||||
.in("email", normalizedEmails);
|
||||
if (error) throw error;
|
||||
|
||||
const found = new Set(
|
||||
(data ?? [])
|
||||
.map((row) => normalizeEmail(row.email))
|
||||
.filter(Boolean),
|
||||
);
|
||||
return normalizedEmails.filter((email) => !found.has(email));
|
||||
}
|
||||
|
||||
export async function syncProfileEmail(
|
||||
db: Db,
|
||||
userId: string,
|
||||
email: string | null | undefined,
|
||||
) {
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
if (!userId || !normalizedEmail) return null;
|
||||
|
||||
const { data: existing, error: loadError } = await db
|
||||
.from("user_profiles")
|
||||
.select("email")
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
if (loadError) return loadError;
|
||||
|
||||
if (!existing) {
|
||||
const { error } = await db.from("user_profiles").insert({
|
||||
user_id: userId,
|
||||
email: normalizedEmail,
|
||||
});
|
||||
return error;
|
||||
}
|
||||
|
||||
if (normalizeEmail(existing.email) === normalizedEmail) return null;
|
||||
|
||||
const { error } = await db
|
||||
.from("user_profiles")
|
||||
.update({
|
||||
email: normalizedEmail,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("user_id", userId);
|
||||
return error;
|
||||
}
|
||||
|
|
@ -32,12 +32,25 @@ type WorkflowRecord = {
|
|||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type WorkflowType = "assistant" | "tabular";
|
||||
|
||||
type WorkflowContributor = {
|
||||
name: string;
|
||||
organisation: string | null;
|
||||
role: string | null;
|
||||
linkedin: string | null;
|
||||
};
|
||||
|
||||
type WorkflowMetadata = {
|
||||
title: string;
|
||||
description: string | null;
|
||||
type: WorkflowType;
|
||||
contributors: WorkflowContributor[];
|
||||
language: string;
|
||||
version: string | null;
|
||||
practice: string | null;
|
||||
jurisdictions: string[] | null;
|
||||
};
|
||||
type OpenSourceSubmissionStatus = "pending" | "approved" | "rejected";
|
||||
|
||||
type OpenSourceSubmissionRow = {
|
||||
|
|
@ -52,7 +65,6 @@ type OpenSourceSubmissionRow = {
|
|||
submitted_at: string;
|
||||
updated_at: string;
|
||||
reviewed_at?: string | null;
|
||||
reviewed_by_user_id?: string | null;
|
||||
review_notes?: string | null;
|
||||
};
|
||||
|
||||
|
|
@ -120,9 +132,42 @@ function withSystemWorkflowAccess(workflow: SystemWorkflow) {
|
|||
});
|
||||
}
|
||||
|
||||
function withDatabaseWorkflow<T extends object>(workflow: T) {
|
||||
function workflowTypeFrom(value: unknown): WorkflowType {
|
||||
return value === "tabular" ? "tabular" : "assistant";
|
||||
}
|
||||
|
||||
function metadataFromWorkflowRecord(workflow: WorkflowRecord): WorkflowMetadata {
|
||||
return {
|
||||
...workflow,
|
||||
title: workflow.title ?? "",
|
||||
description: null,
|
||||
type: workflowTypeFrom(workflow.type),
|
||||
contributors:
|
||||
normalizeContributors(workflow.contributors) ?? [
|
||||
DEFAULT_WORKFLOW_CONTRIBUTOR,
|
||||
],
|
||||
language: workflow.language ?? DEFAULT_WORKFLOW_LANGUAGE,
|
||||
version: workflow.version ?? null,
|
||||
practice: workflow.practice ?? DEFAULT_WORKFLOW_PRACTICE,
|
||||
jurisdictions: workflow.jurisdictions ?? DEFAULT_WORKFLOW_JURISDICTIONS,
|
||||
};
|
||||
}
|
||||
|
||||
function withDatabaseWorkflow(workflow: WorkflowRecord) {
|
||||
const {
|
||||
title: _title,
|
||||
type: _type,
|
||||
contributors: _contributors,
|
||||
language: _language,
|
||||
version: _version,
|
||||
practice: _practice,
|
||||
jurisdictions: _jurisdictions,
|
||||
prompt_md,
|
||||
...rest
|
||||
} = workflow;
|
||||
return {
|
||||
...rest,
|
||||
metadata: metadataFromWorkflowRecord(workflow),
|
||||
skill_md: prompt_md ?? null,
|
||||
is_system: false,
|
||||
};
|
||||
}
|
||||
|
|
@ -179,7 +224,7 @@ async function resolveWorkflowAccess(
|
|||
.eq("id", workflowId)
|
||||
.single();
|
||||
if (!workflow) return null;
|
||||
const workflowRecord = withDatabaseWorkflow(workflow as WorkflowRecord);
|
||||
const workflowRecord = workflow as WorkflowRecord;
|
||||
if (workflowRecord.user_id === userId) {
|
||||
return { workflow: workflowRecord, allowEdit: true, isOwner: true };
|
||||
}
|
||||
|
|
@ -234,16 +279,13 @@ function buildOpenSourceSnapshot(
|
|||
) {
|
||||
return {
|
||||
workflow_id: workflow.id,
|
||||
title: workflow.title ?? "",
|
||||
type: workflow.type ?? "",
|
||||
prompt_md: workflow.prompt_md ?? null,
|
||||
metadata: {
|
||||
...metadataFromWorkflowRecord(workflow),
|
||||
contributors,
|
||||
},
|
||||
skill_md: workflow.prompt_md ?? null,
|
||||
columns_config: workflow.columns_config ?? null,
|
||||
contributors,
|
||||
contributor_mode: contributorMode,
|
||||
language: workflow.language ?? DEFAULT_WORKFLOW_LANGUAGE,
|
||||
version: workflow.version ?? null,
|
||||
practice: workflow.practice ?? DEFAULT_WORKFLOW_PRACTICE,
|
||||
jurisdictions: workflow.jurisdictions ?? DEFAULT_WORKFLOW_JURISDICTIONS,
|
||||
created_at: workflow.created_at ?? null,
|
||||
};
|
||||
}
|
||||
|
|
@ -280,11 +322,11 @@ workflowsRouter.get("/", requireAuth, asyncRoute(async (req, res) => {
|
|||
}
|
||||
|
||||
const systemWorkflows = SYSTEM_WORKFLOWS.filter(
|
||||
(workflow) => !workflowType || workflow.type === workflowType,
|
||||
(workflow) => !workflowType || workflow.metadata.type === workflowType,
|
||||
).map(withSystemWorkflowAccess);
|
||||
const databaseWorkflows = ((data ?? []) as WorkflowRecord[]).filter(
|
||||
(workflow) => !SYSTEM_WORKFLOW_IDS.has(workflow.id),
|
||||
);
|
||||
).map(withDatabaseWorkflow);
|
||||
|
||||
res.json([...systemWorkflows, ...databaseWorkflows]);
|
||||
}));
|
||||
|
|
@ -293,40 +335,36 @@ workflowsRouter.get("/", requireAuth, asyncRoute(async (req, res) => {
|
|||
workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => {
|
||||
const userId = res.locals.userId as string;
|
||||
const {
|
||||
title,
|
||||
type,
|
||||
prompt_md,
|
||||
metadata,
|
||||
skill_md,
|
||||
columns_config,
|
||||
language,
|
||||
practice,
|
||||
jurisdictions,
|
||||
} = req.body as {
|
||||
title: string;
|
||||
type: string;
|
||||
prompt_md?: string;
|
||||
metadata?: Partial<WorkflowMetadata>;
|
||||
skill_md?: string;
|
||||
columns_config?: unknown;
|
||||
language?: unknown;
|
||||
practice?: string | null;
|
||||
jurisdictions?: unknown;
|
||||
};
|
||||
const title = metadata?.title;
|
||||
const type = metadata?.type;
|
||||
if (!title?.trim())
|
||||
return void res.status(400).json({ detail: "title is required" });
|
||||
if (!["assistant", "tabular"].includes(type))
|
||||
return void res.status(400).json({ detail: "metadata.title is required" });
|
||||
if (type !== "assistant" && type !== "tabular")
|
||||
return void res
|
||||
.status(400)
|
||||
.json({ detail: "type must be 'assistant' or 'tabular'" });
|
||||
.json({ detail: "metadata.type must be 'assistant' or 'tabular'" });
|
||||
|
||||
const db = createServerSupabase();
|
||||
devLog("[workflows/create] request", {
|
||||
userId,
|
||||
title: title.trim(),
|
||||
type,
|
||||
hasPrompt: typeof prompt_md === "string" && prompt_md.length > 0,
|
||||
hasSkill: typeof skill_md === "string" && skill_md.length > 0,
|
||||
columnCount: Array.isArray(columns_config) ? columns_config.length : null,
|
||||
language: normalizeOptionalString(language) ?? DEFAULT_WORKFLOW_LANGUAGE,
|
||||
practice: practice ?? null,
|
||||
language:
|
||||
normalizeOptionalString(metadata?.language) ?? DEFAULT_WORKFLOW_LANGUAGE,
|
||||
practice: metadata?.practice ?? null,
|
||||
jurisdictions:
|
||||
normalizeJurisdictions(jurisdictions) ?? DEFAULT_WORKFLOW_JURISDICTIONS,
|
||||
normalizeJurisdictions(metadata?.jurisdictions) ??
|
||||
DEFAULT_WORKFLOW_JURISDICTIONS,
|
||||
});
|
||||
const { data, error } = await db
|
||||
.from("workflows")
|
||||
|
|
@ -334,13 +372,15 @@ workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => {
|
|||
user_id: userId,
|
||||
title: title.trim(),
|
||||
type,
|
||||
prompt_md: prompt_md ?? null,
|
||||
prompt_md: skill_md ?? null,
|
||||
columns_config: columns_config ?? null,
|
||||
language: normalizeOptionalString(language) ?? DEFAULT_WORKFLOW_LANGUAGE,
|
||||
language:
|
||||
normalizeOptionalString(metadata?.language) ?? DEFAULT_WORKFLOW_LANGUAGE,
|
||||
practice:
|
||||
normalizeOptionalString(practice) ?? DEFAULT_WORKFLOW_PRACTICE,
|
||||
normalizeOptionalString(metadata?.practice) ?? DEFAULT_WORKFLOW_PRACTICE,
|
||||
jurisdictions:
|
||||
normalizeJurisdictions(jurisdictions) ?? DEFAULT_WORKFLOW_JURISDICTIONS,
|
||||
normalizeJurisdictions(metadata?.jurisdictions) ??
|
||||
DEFAULT_WORKFLOW_JURISDICTIONS,
|
||||
})
|
||||
.select("*")
|
||||
.single();
|
||||
|
|
@ -362,7 +402,7 @@ workflowsRouter.post("/", requireAuth, asyncRoute(async (req, res) => {
|
|||
title: data?.title,
|
||||
type: data?.type,
|
||||
});
|
||||
res.status(201).json(withDatabaseWorkflow(data));
|
||||
res.status(201).json(withDatabaseWorkflow(data as WorkflowRecord));
|
||||
}));
|
||||
|
||||
async function handleWorkflowUpdate(req: Request, res: Response) {
|
||||
|
|
@ -370,15 +410,17 @@ async function handleWorkflowUpdate(req: Request, res: Response) {
|
|||
const userEmail = res.locals.userEmail as string | undefined;
|
||||
const { workflowId } = req.params;
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (req.body.title != null) updates.title = req.body.title;
|
||||
if (req.body.prompt_md != null) updates.prompt_md = req.body.prompt_md;
|
||||
const metadata = req.body.metadata as Partial<WorkflowMetadata> | undefined;
|
||||
if (metadata?.title != null) updates.title = metadata.title;
|
||||
if (req.body.skill_md != null) updates.prompt_md = req.body.skill_md;
|
||||
if (req.body.columns_config != null)
|
||||
updates.columns_config = req.body.columns_config;
|
||||
if ("language" in req.body)
|
||||
updates.language = normalizeOptionalString(req.body.language);
|
||||
if ("practice" in req.body) updates.practice = req.body.practice ?? null;
|
||||
if ("jurisdictions" in req.body)
|
||||
updates.jurisdictions = normalizeJurisdictions(req.body.jurisdictions);
|
||||
if (metadata && "language" in metadata)
|
||||
updates.language = normalizeOptionalString(metadata.language);
|
||||
if (metadata && "practice" in metadata)
|
||||
updates.practice = metadata.practice ?? null;
|
||||
if (metadata && "jurisdictions" in metadata)
|
||||
updates.jurisdictions = normalizeJurisdictions(metadata.jurisdictions);
|
||||
|
||||
const db = createServerSupabase();
|
||||
const access = await resolveWorkflowAccess(workflowId, userId, userEmail, db);
|
||||
|
|
@ -398,7 +440,7 @@ async function handleWorkflowUpdate(req: Request, res: Response) {
|
|||
.status(404)
|
||||
.json({ detail: "Workflow not found or not editable" });
|
||||
res.json(
|
||||
withWorkflowAccess(withDatabaseWorkflow(data), {
|
||||
withWorkflowAccess(withDatabaseWorkflow(data as WorkflowRecord), {
|
||||
allowEdit: access.allowEdit,
|
||||
isOwner: access.isOwner,
|
||||
}),
|
||||
|
|
@ -506,7 +548,7 @@ workflowsRouter.post("/:workflowId/open-source", requireAuth, asyncRoute(async (
|
|||
.json({ detail: "Workflow not found or not open-sourceable" });
|
||||
}
|
||||
|
||||
const workflowRecord = withDatabaseWorkflow(workflow as WorkflowRecord);
|
||||
const workflowRecord = workflow as WorkflowRecord;
|
||||
const validationError = validateOpenSourceWorkflow(workflowRecord);
|
||||
if (validationError) {
|
||||
return void res.status(400).json({ detail: validationError });
|
||||
|
|
@ -620,7 +662,7 @@ workflowsRouter.get("/:workflowId", requireAuth, asyncRoute(async (req, res) =>
|
|||
: null;
|
||||
res.json(
|
||||
withOpenSourceSubmission(
|
||||
withWorkflowAccess(access.workflow, {
|
||||
withWorkflowAccess(withDatabaseWorkflow(access.workflow), {
|
||||
allowEdit: access.allowEdit,
|
||||
isOwner: access.isOwner,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1025.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1025.0",
|
||||
"@fortune-sheet/core": "^1.0.4",
|
||||
"@fortune-sheet/react": "^1.0.4",
|
||||
"@opennextjs/cloudflare": "^1.19.9",
|
||||
"@openrouter/sdk": "^0.3.11",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
|
|
@ -15,6 +17,8 @@
|
|||
"@supabase/auth-helpers-nextjs": "^0.10.0",
|
||||
"@supabase/auth-js": "^2.101.1",
|
||||
"@supabase/supabase-js": "^2.81.1",
|
||||
"@tiptap/core": "^3.22.3",
|
||||
"@tiptap/extension-table": "^3.22.3",
|
||||
"@tiptap/pm": "^3.22.3",
|
||||
"@tiptap/react": "^3.22.3",
|
||||
"@tiptap/starter-kit": "^3.22.3",
|
||||
|
|
@ -28,6 +32,7 @@
|
|||
"exceljs": "^4.4.0",
|
||||
"katex": "^0.16.27",
|
||||
"lucide-react": "^0.553.0",
|
||||
"luckyexcel": "^1.0.1",
|
||||
"mammoth": "^1.11.0",
|
||||
"marked": "^17.0.1",
|
||||
"next": "^16.2.6",
|
||||
|
|
@ -329,6 +334,14 @@
|
|||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
|
||||
"@formulajs/formulajs": ["@formulajs/formulajs@2.9.3", "", { "dependencies": { "bessel": "^1.0.2", "jstat": "^1.9.2" }, "bin": { "implementation-stats": "bin/implementation-stats" } }, "sha512-WpgiuJaBl/Hcda9Ti8a7mlnw/vUZkJrtjABvojz6P6mo6d8EscudyA7iAt/kTLsgi0c8zfmzQd/Yjb4ASFkw+g=="],
|
||||
|
||||
"@fortune-sheet/core": ["@fortune-sheet/core@1.0.4", "", { "dependencies": { "@fortune-sheet/formula-parser": "^0.2.13", "dayjs": "^1.11.0", "immer": "^9.0.12", "lodash": "^4.17.21", "numeral": "^2.0.6", "uuid": "^8.3.2" } }, "sha512-CDnUVebfvtT++CymNRw0qnY4sz6zYO3KZazlH+nG6otkRkZ05Bvt7NXqVhmKKSSxIvJR4R6h50abu/dVGBpjpw=="],
|
||||
|
||||
"@fortune-sheet/formula-parser": ["@fortune-sheet/formula-parser@0.2.13", "", { "dependencies": { "@formulajs/formulajs": "^2.9.3", "tiny-emitter": "^2.1.0" } }, "sha512-za2ZVQ5ZfMSPCtL8MdqhCthPip7AoeU4MPyd5UDo4RCl9/arrv1Jz0y755mACVVi4suSZxrzWoJGDkLmofoSmw=="],
|
||||
|
||||
"@fortune-sheet/react": ["@fortune-sheet/react@1.0.4", "", { "dependencies": { "@fortune-sheet/core": "^1.0.4", "@types/regenerator-runtime": "^0.13.6", "immer": "^9.0.12", "lodash": "^4.17.21", "regenerator-runtime": "^0.14.1" }, "peerDependencies": { "react": ">= 18.2", "react-dom": ">= 18.2" } }, "sha512-v+BU5mmp2hhUe4gRF+vOJ3j8zZkzHU0kzhTZzp+Nn00wA/RArMr+CO+SAluKlCPAsTZgPwmgOEK685WPqJ5XNw=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||
|
|
@ -745,6 +758,8 @@
|
|||
|
||||
"@tiptap/extension-strike": ["@tiptap/extension-strike@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-aRHWQj42HiailXSC9LkKYM3jWMcSeGwOjbqM4PiuxQZmHVDRFmeHkfJItOdn2cSHaO0vuEVK+TvrWUWsBFi3pg=="],
|
||||
|
||||
"@tiptap/extension-table": ["@tiptap/extension-table@3.27.3", "", { "peerDependencies": { "@tiptap/core": "3.27.3", "@tiptap/pm": "3.27.3" } }, "sha512-P7iQ0SkkDWZwnXzQDOVFLXktXlX67VQ3s2YxoJYKTHlc8rPZ4UCaSogFq0G2APyBlizlY3LbMNrDeZAVFOD8wQ=="],
|
||||
|
||||
"@tiptap/extension-text": ["@tiptap/extension-text@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-mM69uUW5cSxIhyEpWXi/YcfyupcJMDLCPEfYi62awH0iOP/LRoCv/nHjJq4Hyj/KxRJbe8HKwIUnqaCUf7m5Pg=="],
|
||||
|
||||
"@tiptap/extension-underline": ["@tiptap/extension-underline@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-08kGdbhIrA6h10GWXqOkqIveaBj5tmxclK208/nUIAlonI9hPd739vu7fmVtpnmqCnSSNpoRtU4u6Gj5at0ZpA=="],
|
||||
|
|
@ -819,6 +834,8 @@
|
|||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/regenerator-runtime": ["@types/regenerator-runtime@0.13.8", "", {}, "sha512-jjKoBekfYDH331060tZhosdJVDnXIXx+T8Iw2h2T4HEds6Ddb2lr0JxD15+XPKlXwRHRNgZoY+4Fb2ykoqzHBg=="],
|
||||
|
||||
"@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
|
@ -973,6 +990,8 @@
|
|||
|
||||
"bcp-47-match": ["bcp-47-match@2.0.3", "", {}, "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ=="],
|
||||
|
||||
"bessel": ["bessel@1.0.2", "", {}, "sha512-Al3nHGQGqDYqqinXhQzmwmcRToe/3WyBv4N8aZc5Pef8xw2neZlR9VPi84Sa23JtgWcucu18HxVZrnI0fn2etw=="],
|
||||
|
||||
"big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="],
|
||||
|
||||
"binary": ["binary@0.3.0", "", { "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" } }, "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg=="],
|
||||
|
|
@ -1429,7 +1448,7 @@
|
|||
|
||||
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
|
||||
|
||||
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
|
||||
"immer": ["immer@9.0.21", "", {}, "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
|
|
@ -1541,6 +1560,8 @@
|
|||
|
||||
"json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
|
||||
|
||||
"jstat": ["jstat@1.9.6", "", {}, "sha512-rPBkJbK2TnA8pzs93QcDDPlKcrtZWuuCo2dVR0TFLOJSxhqfWOVCSp8aV3/oSbn+4uY4yw1URtLpHQedtmXfug=="],
|
||||
|
||||
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
|
||||
|
||||
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
|
||||
|
|
@ -1593,6 +1614,8 @@
|
|||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||
|
||||
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
|
||||
|
||||
"lodash.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="],
|
||||
|
|
@ -1631,6 +1654,8 @@
|
|||
|
||||
"lucide-react": ["lucide-react@0.553.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw=="],
|
||||
|
||||
"luckyexcel": ["luckyexcel@1.0.1", "", { "dependencies": { "jszip": "^3.5.0" } }, "sha512-hvbJmCXNp/vST/huA6sieDn32Ib8bd80L9aIu5ZGxniJvZle7VlpHZrl6weLGaEnX99+t7cPAoYGqrqbfZp/AQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"mammoth": ["mammoth@1.11.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ=="],
|
||||
|
|
@ -1795,6 +1820,8 @@
|
|||
|
||||
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
"numeral": ["numeral@2.0.6", "", {}, "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
|
@ -1951,6 +1978,8 @@
|
|||
|
||||
"refractor": ["refractor@5.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^9.0.0", "parse-entities": "^4.0.0" } }, "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw=="],
|
||||
|
||||
"regenerator-runtime": ["regenerator-runtime@0.14.1", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="],
|
||||
|
||||
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
||||
|
||||
"rehype": ["rehype@13.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", "unified": "^11.0.0" } }, "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A=="],
|
||||
|
|
@ -2131,6 +2160,8 @@
|
|||
|
||||
"terser": ["terser@5.16.9", "", { "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg=="],
|
||||
|
||||
"tiny-emitter": ["tiny-emitter@2.1.0", "", {}, "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
|
@ -2511,6 +2542,8 @@
|
|||
|
||||
"readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
||||
|
||||
"recharts/immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
|
||||
|
||||
"rehype-attr/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
|
||||
|
||||
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"prebuild": "node ../scripts/build-workflows.js",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
import { accountGlassSectionClassName } from "./accountStyles";
|
||||
|
||||
export function AccountSection({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
type AccountToggleSize = "sm" | "md";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
|
||||
export const accountGlassInputClassName = cn(
|
||||
"rounded-lg px-3 text-gray-900 placeholder:text-gray-400",
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import { Input } from "@/app/components/ui/input";
|
||||
import { useUserProfile } from "@/app/contexts/UserProfileContext";
|
||||
import {
|
||||
MfaVerificationPopup,
|
||||
needsMfaVerification,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
Plus,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Input } from "@/app/components/ui/input";
|
||||
import { Modal } from "@/app/components/modals/Modal";
|
||||
import { NewMcpModal } from "@/app/components/account/NewMcpModal";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import { useUserProfile } from "@/app/contexts/UserProfileContext";
|
||||
import { AccountSection } from "../AccountSection";
|
||||
|
||||
export default function FeaturesPage() {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useEffect } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useAuth } from "@/app/contexts/AuthContext";
|
||||
import { accountTabButtonClassName } from "./accountStyles";
|
||||
|
||||
interface TabDef {
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import {
|
|||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
} from "@/app/components/ui/dropdown-menu";
|
||||
import { useUserProfile } from "@/app/contexts/UserProfileContext";
|
||||
import type { ApiKeyState } from "@/app/lib/mikeApi";
|
||||
import {
|
||||
MODELS,
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LogOut, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import { Button } from "@/app/components/ui/button";
|
||||
import { Input } from "@/app/components/ui/input";
|
||||
import { useAuth } from "@/app/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/app/contexts/UserProfileContext";
|
||||
import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
|
||||
import {
|
||||
MfaVerificationPopup,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { Download, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Button } from "@/app/components/ui/button";
|
||||
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
|
||||
import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import {
|
|||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import { Copy, Loader2 } from "lucide-react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import { supabase } from "@/app/lib/supabase";
|
||||
import { Button } from "@/app/components/ui/button";
|
||||
import { useUserProfile } from "@/app/contexts/UserProfileContext";
|
||||
import { isMfaRequiredError } from "@/app/lib/mikeApi";
|
||||
import { Modal } from "@/app/components/modals/Modal";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useCallback, useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PanelLeft } from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useAuth } from "@/app/contexts/AuthContext";
|
||||
import { ChatHistoryProvider } from "@/app/contexts/ChatHistoryContext";
|
||||
import { SidebarContext } from "@/app/contexts/SidebarContext";
|
||||
import { PageChromeContext } from "@/app/contexts/PageChromeContext";
|
||||
|
|
|
|||
|
|
@ -43,9 +43,9 @@ import { PdfView } from "@/app/components/shared/views/PdfView";
|
|||
import { SpreadsheetView } from "@/app/components/shared/views/SpreadsheetView";
|
||||
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
|
||||
import { DocxView } from "@/app/components/shared/views/DocxView";
|
||||
import { MikeIcon } from "@/components/chat/mike-icon";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import { MikeIcon } from "@/app/components/chat/mike-icon";
|
||||
import { useAuth } from "@/app/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/app/contexts/UserProfileContext";
|
||||
import { useSidebar } from "@/app/contexts/SidebarContext";
|
||||
import { PageHeader } from "@/app/components/shared/PageHeader";
|
||||
import { HeaderActionsMenu } from "@/app/components/shared/HeaderActionsMenu";
|
||||
|
|
@ -1116,7 +1116,6 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
)
|
||||
}
|
||||
rounded={false}
|
||||
bordered={false}
|
||||
/>
|
||||
) : isSpreadsheetFilename(activeTab.filename) ? (
|
||||
<SpreadsheetView
|
||||
|
|
@ -1124,7 +1123,6 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
documentId={activeTab.documentId}
|
||||
versionId={activeTab.versionId}
|
||||
rounded={false}
|
||||
bordered={false}
|
||||
/>
|
||||
) : (
|
||||
<PdfView
|
||||
|
|
@ -1132,7 +1130,6 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
doc={{ document_id: activeTab.documentId }}
|
||||
quotes={activeQuotes ?? undefined}
|
||||
rounded={false}
|
||||
bordered={false}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
useProjectWorkspace,
|
||||
} from "@/app/components/projects/ProjectWorkspace";
|
||||
import type { Chat } from "@/app/components/shared/types";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useAuth } from "@/app/contexts/AuthContext";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
useProjectWorkspace,
|
||||
} from "@/app/components/projects/ProjectWorkspace";
|
||||
import type { TabularReview } from "@/app/components/shared/types";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useAuth } from "@/app/contexts/AuthContext";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { TableToolbar } from "@/app/components/shared/TableToolbar";
|
|||
import { NewTRModal } from "@/app/components/tabular/NewTRModal";
|
||||
import { TabularReviewDetailsModal } from "@/app/components/tabular/TabularReviewDetailsModal";
|
||||
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useAuth } from "@/app/contexts/AuthContext";
|
||||
import { PageHeader } from "@/app/components/shared/PageHeader";
|
||||
import {
|
||||
GLASS_DROPDOWN,
|
||||
|
|
|
|||
333
frontend/src/app/components/account/NewMcpModal.tsx
Normal file
333
frontend/src/app/components/account/NewMcpModal.tsx
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
"use client";
|
||||
|
||||
import { Check, ChevronDown, Eye, EyeOff, Loader2 } from "lucide-react";
|
||||
import { Input } from "@/app/components/ui/input";
|
||||
import { Modal } from "@/app/components/modals/Modal";
|
||||
import type { McpConnectorSummary } from "@/app/lib/mikeApi";
|
||||
import {
|
||||
accountGlassIconButtonClassName,
|
||||
accountGlassInputClassName,
|
||||
} from "@/app/(pages)/account/accountStyles";
|
||||
|
||||
export type NewMcpDraft = {
|
||||
name: string;
|
||||
serverUrl: string;
|
||||
bearerToken: string;
|
||||
customHeaders: string;
|
||||
};
|
||||
|
||||
export type NewMcpStep = "form" | "working" | "auth" | "success";
|
||||
|
||||
interface NewMcpModalProps {
|
||||
open: boolean;
|
||||
draft: NewMcpDraft;
|
||||
step: NewMcpStep;
|
||||
result: McpConnectorSummary | null;
|
||||
error: string | null;
|
||||
authMessage: string | null;
|
||||
showToken: boolean;
|
||||
showAdvanced: boolean;
|
||||
onDraftChange: (draft: NewMcpDraft) => void;
|
||||
onShowTokenChange: (show: boolean) => void;
|
||||
onShowAdvancedChange: (show: boolean) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: () => Promise<void>;
|
||||
onOpenConnector: (connectorId: string) => void;
|
||||
}
|
||||
|
||||
export function NewMcpModal({
|
||||
open,
|
||||
draft,
|
||||
step,
|
||||
result,
|
||||
error,
|
||||
authMessage,
|
||||
showToken,
|
||||
showAdvanced,
|
||||
onDraftChange,
|
||||
onShowTokenChange,
|
||||
onShowAdvancedChange,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onOpenConnector,
|
||||
}: NewMcpModalProps) {
|
||||
const canSubmit =
|
||||
draft.name.trim().length > 0 &&
|
||||
draft.serverUrl.trim().length > 0 &&
|
||||
step !== "working" &&
|
||||
step !== "auth";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
breadcrumbs={[
|
||||
"Connectors",
|
||||
step === "success"
|
||||
? "Connector added"
|
||||
: step === "auth"
|
||||
? "Authenticate connector"
|
||||
: "New MCP connector",
|
||||
]}
|
||||
size="lg"
|
||||
primaryAction={
|
||||
step === "success" && result
|
||||
? {
|
||||
label: "View connector",
|
||||
onClick: () => onOpenConnector(result.id),
|
||||
}
|
||||
: {
|
||||
label:
|
||||
step === "working"
|
||||
? "Connecting..."
|
||||
: step === "auth"
|
||||
? "Authorizing..."
|
||||
: "Connect",
|
||||
icon:
|
||||
step === "working" || step === "auth" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : undefined,
|
||||
onClick: () => void onSubmit(),
|
||||
disabled: !canSubmit,
|
||||
}
|
||||
}
|
||||
cancelAction={
|
||||
step === "working" || step === "auth"
|
||||
? false
|
||||
: {
|
||||
label: step === "success" ? "Done" : "Cancel",
|
||||
onClick: onClose,
|
||||
}
|
||||
}
|
||||
footerStatus={
|
||||
error ? (
|
||||
<div className="rounded-xl border border-white/70 bg-white/75 px-3 py-2 text-sm text-red-600 shadow-[0_12px_32px_rgba(15,23,42,0.10),inset_0_1px_0_rgba(255,255,255,0.75)] backdrop-blur-xl">
|
||||
{error}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{step === "success" && result ? (
|
||||
<NewMcpSuccess connector={result} />
|
||||
) : step === "auth" ? (
|
||||
<NewMcpAuth
|
||||
message={
|
||||
authMessage ??
|
||||
"Complete authorization in the popup to finish connecting this MCP server."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pb-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
The assistant will have access to this MCP server and
|
||||
its enabled tools.
|
||||
</p>
|
||||
<NewMcpForm
|
||||
draft={draft}
|
||||
showToken={showToken}
|
||||
showAdvanced={showAdvanced}
|
||||
disabled={step === "working"}
|
||||
onDraftChange={onDraftChange}
|
||||
onShowTokenChange={onShowTokenChange}
|
||||
onShowAdvancedChange={onShowAdvancedChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function NewMcpForm({
|
||||
draft,
|
||||
showToken,
|
||||
showAdvanced,
|
||||
disabled,
|
||||
onDraftChange,
|
||||
onShowTokenChange,
|
||||
onShowAdvancedChange,
|
||||
}: {
|
||||
draft: NewMcpDraft;
|
||||
showToken: boolean;
|
||||
showAdvanced: boolean;
|
||||
disabled: boolean;
|
||||
onDraftChange: (draft: NewMcpDraft) => void;
|
||||
onShowTokenChange: (show: boolean) => void;
|
||||
onShowAdvancedChange: (show: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-3 pt-1">
|
||||
<label className="grid gap-2 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-center">
|
||||
<span className="text-xs font-medium text-gray-500">
|
||||
Label
|
||||
</span>
|
||||
<Input
|
||||
value={draft.name}
|
||||
onChange={(event) =>
|
||||
onDraftChange({ ...draft, name: event.target.value })
|
||||
}
|
||||
placeholder="Connector label"
|
||||
className={`h-8 text-sm ${accountGlassInputClassName}`}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-center">
|
||||
<span className="text-xs font-medium text-gray-500">
|
||||
URL endpoint
|
||||
</span>
|
||||
<Input
|
||||
value={draft.serverUrl}
|
||||
onChange={(event) =>
|
||||
onDraftChange({
|
||||
...draft,
|
||||
serverUrl: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="https://mcp.example.com/mcp"
|
||||
className={`h-8 text-sm ${accountGlassInputClassName}`}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</label>
|
||||
<div className="grid gap-2 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-start">
|
||||
<span className="pt-2 text-xs font-medium text-gray-500">
|
||||
Bearer token
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={draft.bearerToken}
|
||||
onChange={(event) =>
|
||||
onDraftChange({
|
||||
...draft,
|
||||
bearerToken: event.target.value,
|
||||
})
|
||||
}
|
||||
type={showToken ? "text" : "password"}
|
||||
placeholder="Bearer token"
|
||||
className={`h-8 pr-10 text-sm ${accountGlassInputClassName}`}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{draft.bearerToken && (
|
||||
<button
|
||||
type="button"
|
||||
className={`absolute inset-y-1 right-1.5 flex items-center ${accountGlassIconButtonClassName}`}
|
||||
onClick={() => onShowTokenChange(!showToken)}
|
||||
aria-label={
|
||||
showToken ? "Hide token" : "Show token"
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
{showToken ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-right text-xs text-gray-500">
|
||||
Tokens are stored encrypted.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onShowAdvancedChange(!showAdvanced)}
|
||||
className="inline-flex items-center gap-1 justify-self-start text-xs font-medium text-gray-500 transition-colors hover:text-gray-900"
|
||||
disabled={disabled}
|
||||
>
|
||||
Advanced
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 transition-transform ${
|
||||
showAdvanced ? "" : "-rotate-90"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<label className="grid gap-2 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-start">
|
||||
<span className="text-xs font-medium text-gray-500">
|
||||
Custom headers
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<textarea
|
||||
value={draft.customHeaders}
|
||||
onChange={(event) =>
|
||||
onDraftChange({
|
||||
...draft,
|
||||
customHeaders: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder='{"X-API-Key":"secret"}'
|
||||
className={`min-h-20 w-full resize-y rounded-lg px-3 py-2 text-sm outline-none ${accountGlassInputClassName}`}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="mt-1 text-right text-xs text-gray-500">
|
||||
Secrets are stored encrypted.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewMcpSuccess({ connector }: { connector: McpConnectorSummary }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col gap-4 pb-4">
|
||||
<div className="flex items-start gap-3 rounded-xl border border-green-100/80 bg-green-50/80 px-3 py-3 text-green-800 shadow-[0_3px_9px_rgba(15,23,42,0.03),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-xl">
|
||||
<Check className="mt-0.5 h-4 w-4 shrink-0 text-green-600" />
|
||||
<p className="min-w-0 truncate text-sm font-medium">
|
||||
{connector.name} is connected.{" "}
|
||||
<span className="font-normal text-green-700">
|
||||
{connector.tools.length} tools discovered.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-gray-100 bg-white/60">
|
||||
<div className="max-h-full overflow-y-auto divide-y divide-gray-100">
|
||||
{connector.tools.map((tool) => (
|
||||
<div
|
||||
key={tool.openaiToolName}
|
||||
className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-gray-800">
|
||||
{tool.title ?? tool.openaiToolName}
|
||||
</p>
|
||||
{tool.description && (
|
||||
<p className="truncate text-xs text-gray-500">
|
||||
{tool.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">
|
||||
{tool.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewMcpAuth({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 pb-4 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-white/70 bg-white/75 text-gray-700 shadow-[0_3px_9px_rgba(15,23,42,0.03),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-xl">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
<div className="max-w-sm space-y-1">
|
||||
<h3 className="text-sm font-medium text-gray-900">
|
||||
Authentication required
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,116 +1,40 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { PlusIcon, Upload, LayoutGridIcon, Loader2Icon } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { uploadStandaloneDocument } from "@/app/lib/mikeApi";
|
||||
import type { Document } from "../shared/types";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
onSelectDoc: (doc: Document) => void;
|
||||
onBrowseAll: () => void;
|
||||
selectedDocIds?: string[];
|
||||
hideLabel?: boolean;
|
||||
}
|
||||
|
||||
export function AddDocButton({
|
||||
onSelectDoc,
|
||||
onBrowseAll,
|
||||
selectedDocIds = [],
|
||||
hideLabel = false,
|
||||
}: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploaded = await Promise.all(
|
||||
files.map((f) => uploadStandaloneDocument(f)),
|
||||
);
|
||||
uploaded.forEach((doc) => onSelectDoc(doc));
|
||||
} catch (err) {
|
||||
console.error("Upload failed:", err);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.docx,.doc,.xlsx,.xlsm,.xls,.pptx,.ppt"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<DropdownMenu onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={`flex items-center gap-1 px-2 h-8 rounded-lg text-sm transition-colors cursor-pointer ${
|
||||
selectedDocIds.length > 0
|
||||
? "text-black hover:bg-gray-100"
|
||||
: "text-gray-400 hover:text-gray-700 hover:bg-gray-100"
|
||||
} ${isOpen ? "bg-gray-100" : ""}`}
|
||||
title="Add documents"
|
||||
aria-label="Add documents"
|
||||
>
|
||||
{selectedDocIds.length > 0 ? (
|
||||
<span className="font-medium tabular-nums">{selectedDocIds.length}</span>
|
||||
) : (
|
||||
<PlusIcon
|
||||
className={`h-4 w-4 shrink-0 transition-transform duration-300 ${isOpen ? "rotate-[135deg]" : ""}`}
|
||||
/>
|
||||
)}
|
||||
<span className={hideLabel ? "hidden" : "hidden sm:inline"}>
|
||||
{selectedDocIds.length === 1
|
||||
? "Document"
|
||||
: "Documents"}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-44 z-50"
|
||||
side="bottom"
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
disabled={uploading}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2Icon className="h-4 w-4 mr-2 animate-spin text-gray-400" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4 mr-2 text-gray-500" />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
{uploading ? "Uploading…" : "Upload files"}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={onBrowseAll}
|
||||
>
|
||||
<LayoutGridIcon className="h-4 w-4 mr-2 text-gray-500" />
|
||||
<span className="text-sm">Browse all</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBrowseAll}
|
||||
className={`flex items-center gap-1 px-2 h-8 rounded-lg text-sm transition-colors cursor-pointer ${
|
||||
selectedDocIds.length > 0
|
||||
? "text-black hover:bg-gray-100"
|
||||
: "text-gray-400 hover:text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
title="Add documents"
|
||||
aria-label="Add documents"
|
||||
>
|
||||
{selectedDocIds.length > 0 ? (
|
||||
<span className="font-medium tabular-nums">
|
||||
{selectedDocIds.length}
|
||||
</span>
|
||||
) : (
|
||||
<PlusIcon className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className={hideLabel ? "hidden" : "hidden sm:inline"}>
|
||||
{selectedDocIds.length === 1 ? "Document" : "Documents"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
import type { AssistantEvent, Document } from "../shared/types";
|
||||
import { FileTypeIcon } from "../shared/FileTypeIcon";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
CaseLawPanel,
|
||||
type CaseTab,
|
||||
} from "./CaseLawPanel";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab data
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
Download,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { MikeIcon } from "@/components/chat/mike-icon";
|
||||
import { MikeIcon } from "@/app/components/chat/mike-icon";
|
||||
import type { CaseCitationQuote } from "../shared/types";
|
||||
import {
|
||||
clearDocxQuoteHighlights,
|
||||
|
|
@ -27,7 +27,7 @@ import {
|
|||
getCourtlistenerOpinions,
|
||||
type CaseLawOpinion,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
|
||||
export type CaseTab = {
|
||||
kind: "case";
|
||||
|
|
|
|||
|
|
@ -23,14 +23,14 @@ import { AssistantWorkflowModal } from "./AssistantWorkflowModal";
|
|||
import { ApiKeyMissingPopup } from "../popups/ApiKeyMissingPopup";
|
||||
import { ModelToggle } from "./ModelToggle";
|
||||
import { useSelectedModel } from "@/app/hooks/useSelectedModel";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import { useUserProfile } from "@/app/contexts/UserProfileContext";
|
||||
import {
|
||||
getModelProvider,
|
||||
isModelAvailable,
|
||||
type ModelProvider,
|
||||
} from "@/app/lib/modelAvailability";
|
||||
import type { Document, Message } from "../shared/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
|
||||
export interface ChatInputHandle {
|
||||
addDoc: (doc: Document) => void;
|
||||
|
|
@ -96,13 +96,6 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const handleAddDocFromProject = useCallback((doc: Document) => {
|
||||
setAttachedDocs((prev) => {
|
||||
if (prev.some((d) => d.id === doc.id)) return prev;
|
||||
return [...prev, doc];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAddDocsFromSelector = useCallback(
|
||||
(selectedDocs: Document[]) => {
|
||||
setAttachedDocs((prev) => {
|
||||
|
|
@ -244,7 +237,6 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
<div className="flex items-center gap-1">
|
||||
{!hideAddDocButton && (
|
||||
<AddDocButton
|
||||
onSelectDoc={handleAddDocFromProject}
|
||||
onBrowseAll={() => setDocSelectorOpen(true)}
|
||||
selectedDocIds={attachedDocs.map(
|
||||
(d) => d.id,
|
||||
|
|
@ -338,7 +330,10 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
open={workflowModalOpen}
|
||||
onClose={() => setWorkflowModalOpen(false)}
|
||||
onSelect={(wf) => {
|
||||
setSelectedWorkflow({ id: wf.id, title: wf.title });
|
||||
setSelectedWorkflow({
|
||||
id: wf.id,
|
||||
title: wf.metadata.title,
|
||||
});
|
||||
setWorkflowModalOpen(false);
|
||||
}}
|
||||
projectName={projectName}
|
||||
|
|
|
|||
303
frontend/src/app/components/assistant/CitationQuotesHeader.tsx
Normal file
303
frontend/src/app/components/assistant/CitationQuotesHeader.tsx
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Minus, RectangleHorizontal, Rows3 } from "lucide-react";
|
||||
import { CiteButton } from "@/app/components/ui/cite-button";
|
||||
|
||||
export type CitationQuoteHeaderItem = {
|
||||
id: string;
|
||||
quote: string;
|
||||
eyebrow?: string | null;
|
||||
inlineDetail?: string | null;
|
||||
detail?: string | null;
|
||||
citationText?: string | null;
|
||||
};
|
||||
|
||||
const QUOTE_GLASS_SURFACE =
|
||||
"rounded-2xl bg-white/58 shadow-[0_5px_15px_rgba(15,23,42,0.095),inset_0_1px_0_rgba(255,255,255,0.88),inset_0_-8px_16px_rgba(255,255,255,0.16)] backdrop-blur-2xl";
|
||||
const QUOTE_CARD_SURFACE = "rounded-2xl bg-gray-100";
|
||||
|
||||
interface Props {
|
||||
quotes: CitationQuoteHeaderItem[];
|
||||
error?: string | null;
|
||||
isLoading?: boolean;
|
||||
activeQuoteId?: string | null;
|
||||
currentIndex?: number;
|
||||
citationRef?: number;
|
||||
citationText?: string;
|
||||
onSelect?: (quote: CitationQuoteHeaderItem, index: number) => void;
|
||||
onIndexChange?: (index: number) => void;
|
||||
}
|
||||
|
||||
export function CitationQuotesHeader({
|
||||
quotes,
|
||||
error = null,
|
||||
isLoading = false,
|
||||
activeQuoteId = null,
|
||||
currentIndex = 0,
|
||||
citationRef,
|
||||
citationText,
|
||||
onSelect,
|
||||
onIndexChange,
|
||||
}: Props) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [viewMode, setViewMode] = useState<"single" | "list">("single");
|
||||
const hasMultipleQuotes = quotes.length > 1;
|
||||
const currentQuote = quotes[currentIndex];
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMultipleQuotes && viewMode === "list") {
|
||||
setViewMode("single");
|
||||
}
|
||||
}, [hasMultipleQuotes, viewMode]);
|
||||
|
||||
return (
|
||||
<div className="px-3">
|
||||
<div className={QUOTE_GLASS_SURFACE}>
|
||||
<div className="flex h-10 items-center justify-between px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="flex items-center gap-1.5 text-xs font-medium text-gray-700">
|
||||
<span>Citation</span>
|
||||
{typeof citationRef === "number" && (
|
||||
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-gray-200 px-1 text-[9px] font-medium text-gray-600">
|
||||
{citationRef}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasMultipleQuotes && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="mr-0.5 text-xs font-medium text-gray-500">
|
||||
Quotes
|
||||
</span>
|
||||
{quotes.map((quote, index) => (
|
||||
<button
|
||||
key={quote.id}
|
||||
type="button"
|
||||
onClick={() => onIndexChange?.(index)}
|
||||
className={`flex h-4 w-4 items-center justify-center rounded-full text-[9px] transition-colors ${
|
||||
currentIndex === index
|
||||
? "bg-white font-medium text-gray-800 shadow-[0_1px_3px_rgba(0,0,0,0.22)]"
|
||||
: "bg-gray-200 text-gray-500 hover:bg-gray-300 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{index + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{currentQuote && (
|
||||
<CiteButton
|
||||
quoteText={currentQuote.quote}
|
||||
citationText={
|
||||
currentQuote.citationText ??
|
||||
citationText ??
|
||||
""
|
||||
}
|
||||
className="rounded-sm bg-white px-2 h-6 text-gray-600 shadow-[0_1px_3px_rgba(0,0,0,0.22)] hover:bg-gray-50"
|
||||
showText
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`relative flex h-6 items-center justify-start gap-1 rounded-sm bg-gray-200 p-1 ${
|
||||
hasMultipleQuotes ? "w-16" : "w-11"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-1 h-4 w-4 rounded bg-white shadow-sm transition-all ${
|
||||
!isExpanded
|
||||
? "left-1"
|
||||
: hasMultipleQuotes &&
|
||||
viewMode === "list"
|
||||
? "left-11"
|
||||
: "left-6"
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded ${
|
||||
!isExpanded
|
||||
? "text-gray-800"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
title="Minimize"
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsExpanded(true);
|
||||
setViewMode("single");
|
||||
}}
|
||||
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded ${
|
||||
isExpanded && viewMode === "single"
|
||||
? "text-gray-800"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
title="Single quote"
|
||||
>
|
||||
<RectangleHorizontal className="h-3 w-3" />
|
||||
</button>
|
||||
{hasMultipleQuotes && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsExpanded(true);
|
||||
setViewMode("list");
|
||||
}}
|
||||
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded ${
|
||||
isExpanded && viewMode === "list"
|
||||
? "text-gray-800"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
title="Quote list"
|
||||
>
|
||||
<Rows3 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="px-2 pb-2">
|
||||
{isLoading ? (
|
||||
<RelevantQuoteSkeleton />
|
||||
) : error ? (
|
||||
<RelevantQuoteMessage tone="error">
|
||||
{error}
|
||||
</RelevantQuoteMessage>
|
||||
) : quotes.length > 0 ? (
|
||||
viewMode === "list" ? (
|
||||
<div className="space-y-2">
|
||||
{quotes.map((quote, index) => (
|
||||
<QuoteItem
|
||||
key={quote.id}
|
||||
quote={quote}
|
||||
isActive={
|
||||
activeQuoteId === quote.id
|
||||
}
|
||||
onClick={() =>
|
||||
onSelect?.(quote, index)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : currentQuote ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<QuoteItem
|
||||
quote={currentQuote}
|
||||
isActive={
|
||||
activeQuoteId === currentQuote.id
|
||||
}
|
||||
onClick={() =>
|
||||
onSelect?.(
|
||||
currentQuote,
|
||||
currentIndex,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<RelevantQuoteMessage>
|
||||
No relevant quotes.
|
||||
</RelevantQuoteMessage>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RelevantQuoteSkeleton() {
|
||||
return (
|
||||
<div className={`animate-pulse px-3 py-2.5 ${QUOTE_CARD_SURFACE}`}>
|
||||
<div className="h-3 w-28 rounded bg-gray-200" />
|
||||
<div className="mt-2.5 h-3 w-full rounded bg-gray-200" />
|
||||
<div className="mt-2 h-3 w-11/12 rounded bg-gray-200" />
|
||||
<div className="mt-2 h-3 w-2/3 rounded bg-gray-200" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RelevantQuoteMessage({
|
||||
children,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: "neutral" | "error";
|
||||
}) {
|
||||
return (
|
||||
<div className={`px-3 py-2.5 ${QUOTE_CARD_SURFACE}`}>
|
||||
<p
|
||||
className={`font-serif text-sm leading-6 ${
|
||||
tone === "error" ? "text-red-700" : "text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuoteItem({
|
||||
quote,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
quote: CitationQuoteHeaderItem;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`w-full rounded-xl px-3 py-2.5 text-left transition-colors ${
|
||||
isActive ? "bg-blue-100/70" : "bg-gray-100 hover:bg-gray-200/70"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{quote.eyebrow && (
|
||||
<p
|
||||
className={`font-serif text-xs ${
|
||||
isActive ? "text-blue-900" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{quote.eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
className={`font-serif text-sm leading-6 ${
|
||||
isActive ? "text-blue-950" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
“{quote.quote.replace(/"/g, "'")}”
|
||||
{quote.inlineDetail && (
|
||||
<span
|
||||
className={`text-sm ${
|
||||
isActive ? "text-blue-900" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{" "}
|
||||
({quote.inlineDetail})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{quote.detail && (
|
||||
<p
|
||||
className={`font-serif text-xs ${
|
||||
isActive ? "text-blue-900" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{quote.detail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
402
frontend/src/app/components/assistant/DocPanel.tsx
Normal file
402
frontend/src/app/components/assistant/DocPanel.tsx
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { supabase } from "@/app/lib/supabase";
|
||||
import { PdfView } from "../shared/views/PdfView";
|
||||
import { DocxView } from "../shared/views/DocxView";
|
||||
import { SpreadsheetView } from "../shared/views/SpreadsheetView";
|
||||
import {
|
||||
CitationQuotesHeader,
|
||||
type CitationQuoteHeaderItem,
|
||||
} from "./CitationQuotesHeader";
|
||||
import { TrackedChangeHeader } from "./TrackedChangeHeader";
|
||||
import {
|
||||
cleanCitationQuoteText,
|
||||
expandCitationToEntries,
|
||||
formatCitationPage,
|
||||
formatCitationQuotePage,
|
||||
getDocumentCitationQuotes,
|
||||
isSpreadsheetFilename,
|
||||
} from "../shared/types";
|
||||
import type {
|
||||
CitationQuote,
|
||||
Citation,
|
||||
DocumentCitation,
|
||||
EditAnnotation,
|
||||
} from "../shared/types";
|
||||
|
||||
function isDocxFilename(name: string): boolean {
|
||||
const ext = name.split(".").pop()?.toLowerCase();
|
||||
return ext === "docx" || ext === "doc";
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated-union describing what the panel is showing above the viewer.
|
||||
* - "document": title row + viewer.
|
||||
* - "citation": title row + relevant quote + viewer.
|
||||
* - "edit": title row + tracked change + viewer.
|
||||
*/
|
||||
export type DocPanelMode =
|
||||
| { kind: "document" }
|
||||
| { kind: "citation"; citation: Citation }
|
||||
| {
|
||||
kind: "edit";
|
||||
edit: EditAnnotation;
|
||||
changeNumber?: number;
|
||||
/**
|
||||
* True while an accept/reject request for this exact edit is in
|
||||
* flight. Scoped per-edit (not per-document) so sibling edits on
|
||||
* the same doc stay clickable.
|
||||
*/
|
||||
isEditReloading?: boolean;
|
||||
onResolveStart?: (args: {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
verb: "accept" | "reject";
|
||||
}) => void;
|
||||
onResolved?: (args: {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
status: "accepted" | "rejected";
|
||||
versionId: string | null;
|
||||
downloadUrl: string | null;
|
||||
}) => void;
|
||||
onError?: (args: {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
versionId: string | null;
|
||||
message: string;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
documentId: string;
|
||||
filename: string;
|
||||
versionId: string | null;
|
||||
versionNumber: number | null;
|
||||
mode: DocPanelMode;
|
||||
/** Spinner on the Download button while an accept/reject is in flight. */
|
||||
isReloading?: boolean;
|
||||
warning?: string | null;
|
||||
onWarningDismiss?: () => void;
|
||||
initialScrollTop?: number | null;
|
||||
onScrollChange?: (scrollTop: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified side-panel body for the assistant. Renders a single document
|
||||
* with optionally a citation quote OR a tracked change highlighted above
|
||||
* the viewer. No selector UI — caller picks the one thing to show; if the
|
||||
* user wants a different citation/edit, the panel gets a new tab.
|
||||
*/
|
||||
export function DocPanel({
|
||||
documentId,
|
||||
filename,
|
||||
versionId,
|
||||
versionNumber,
|
||||
mode,
|
||||
isReloading = false,
|
||||
warning,
|
||||
onWarningDismiss,
|
||||
initialScrollTop,
|
||||
onScrollChange,
|
||||
}: Props) {
|
||||
// Pick the viewer from the filename only, not from mode. Switching
|
||||
// headers (citation ↔ edit ↔ document) for the same document must
|
||||
// not unmount and remount the body — otherwise the user sees a full
|
||||
// re-fetch every time they toggle. Tracked-change rendering still
|
||||
// only lives in DocxView, which is fine because edits are DOCX-only.
|
||||
const useDocxView = isDocxFilename(filename);
|
||||
const useSheetView = isSpreadsheetFilename(filename);
|
||||
const citationQuoteId =
|
||||
mode.kind === "citation" ? `document:${mode.citation.ref}:0` : null;
|
||||
const [activeCitationQuoteId, setActiveCitationQuoteId] = useState<
|
||||
string | null
|
||||
>(citationQuoteId);
|
||||
const [quoteFocusKey, setQuoteFocusKey] = useState(0);
|
||||
const [editFocusKey, setEditFocusKey] = useState(0);
|
||||
|
||||
const quotes: CitationQuote[] | undefined = useMemo(() => {
|
||||
if (mode.kind !== "citation") return undefined;
|
||||
if (!activeCitationQuoteId) return [];
|
||||
const selectedIndex = Number(activeCitationQuoteId.split(":").at(-1));
|
||||
if (!Number.isFinite(selectedIndex)) return [];
|
||||
const selectedQuote =
|
||||
getDocumentCitationQuotes(mode.citation)[selectedIndex];
|
||||
if (!selectedQuote) return [];
|
||||
const documentCitation = mode.citation as DocumentCitation;
|
||||
return expandCitationToEntries({
|
||||
...documentCitation,
|
||||
page: selectedQuote.page,
|
||||
quote: selectedQuote.quote,
|
||||
quotes: [selectedQuote],
|
||||
});
|
||||
}, [activeCitationQuoteId, citationQuoteId, mode]);
|
||||
|
||||
// Cell locator(s) for the selected quote, used to highlight the cited cell
|
||||
// when the document is a spreadsheet.
|
||||
const highlightCells = useMemo(() => {
|
||||
if (mode.kind !== "citation") return undefined;
|
||||
if (!activeCitationQuoteId) return [];
|
||||
const selectedIndex = Number(activeCitationQuoteId.split(":").at(-1));
|
||||
if (!Number.isFinite(selectedIndex)) return [];
|
||||
const selectedQuote =
|
||||
getDocumentCitationQuotes(mode.citation)[selectedIndex];
|
||||
if (!selectedQuote || (!selectedQuote.cell && !selectedQuote.sheet))
|
||||
return [];
|
||||
return [{ sheet: selectedQuote.sheet, cell: selectedQuote.cell }];
|
||||
}, [activeCitationQuoteId, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveCitationQuoteId(citationQuoteId);
|
||||
}, [citationQuoteId]);
|
||||
|
||||
const handleCitationQuoteSelect = useCallback(
|
||||
(quoteId: string) => {
|
||||
const shouldSelect = activeCitationQuoteId !== quoteId;
|
||||
setActiveCitationQuoteId(shouldSelect ? quoteId : null);
|
||||
if (shouldSelect) setQuoteFocusKey((current) => current + 1);
|
||||
},
|
||||
[activeCitationQuoteId],
|
||||
);
|
||||
|
||||
const highlightEdit = useMemo(() => {
|
||||
if (mode.kind !== "edit") return null;
|
||||
return {
|
||||
key: `${mode.edit.edit_id}:${editFocusKey}`,
|
||||
inserted_text: mode.edit.inserted_text,
|
||||
deleted_text: mode.edit.deleted_text,
|
||||
ins_w_id: mode.edit.ins_w_id ?? null,
|
||||
del_w_id: mode.edit.del_w_id ?? null,
|
||||
};
|
||||
}, [editFocusKey, mode]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<DocumentTitleRow
|
||||
documentId={documentId}
|
||||
filename={filename}
|
||||
versionId={versionId}
|
||||
versionNumber={versionNumber}
|
||||
isReloading={isReloading}
|
||||
/>
|
||||
|
||||
{mode.kind === "citation" && (
|
||||
<RelevantQuoteSection
|
||||
citation={mode.citation}
|
||||
filename={filename}
|
||||
activeQuoteId={activeCitationQuoteId}
|
||||
onQuoteSelect={handleCitationQuoteSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode.kind === "edit" && (
|
||||
<TrackedChangeHeader
|
||||
edit={mode.edit}
|
||||
changeNumber={mode.changeNumber}
|
||||
isEditReloading={mode.isEditReloading}
|
||||
onResolveStart={mode.onResolveStart}
|
||||
onResolved={mode.onResolved}
|
||||
onError={mode.onError}
|
||||
onHighlight={() => setEditFocusKey((current) => current + 1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 min-h-0 flex-col px-3 py-3">
|
||||
{useDocxView ? (
|
||||
<DocxView
|
||||
documentId={documentId}
|
||||
versionId={versionId ?? undefined}
|
||||
quotes={quotes}
|
||||
quoteFocusKey={quoteFocusKey}
|
||||
highlightEdit={highlightEdit}
|
||||
warning={warning ?? null}
|
||||
onWarningDismiss={onWarningDismiss}
|
||||
initialScrollTop={initialScrollTop ?? null}
|
||||
onScrollChange={onScrollChange}
|
||||
/>
|
||||
) : useSheetView ? (
|
||||
<SpreadsheetView
|
||||
documentId={documentId}
|
||||
versionId={versionId}
|
||||
highlightCells={highlightCells}
|
||||
/>
|
||||
) : (
|
||||
<PdfView
|
||||
doc={{
|
||||
document_id: documentId,
|
||||
version_id: versionId,
|
||||
}}
|
||||
quotes={quotes}
|
||||
quoteFocusKey={quoteFocusKey}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DocumentTitleRow({
|
||||
documentId,
|
||||
filename,
|
||||
versionId,
|
||||
versionNumber,
|
||||
isReloading,
|
||||
}: {
|
||||
documentId: string;
|
||||
filename: string;
|
||||
versionId: string | null;
|
||||
versionNumber: number | null;
|
||||
isReloading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 px-3 pt-4 pb-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h2
|
||||
className="min-w-0 break-words font-serif text-xl text-gray-900"
|
||||
title={filename}
|
||||
>
|
||||
{filename}
|
||||
</h2>
|
||||
{versionNumber && versionNumber > 0 && (
|
||||
<span className="shrink-0 inline-flex items-center rounded-md border border-gray-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-gray-600">
|
||||
V{versionNumber}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<DownloadButton
|
||||
documentId={documentId}
|
||||
versionId={versionId}
|
||||
filename={filename}
|
||||
isReloading={isReloading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RelevantQuoteSection({
|
||||
citation,
|
||||
filename,
|
||||
activeQuoteId,
|
||||
onQuoteSelect,
|
||||
}: {
|
||||
citation: Citation;
|
||||
filename: string;
|
||||
activeQuoteId: string | null;
|
||||
onQuoteSelect: (quoteId: string) => void;
|
||||
}) {
|
||||
const citationQuotes = getDocumentCitationQuotes(citation);
|
||||
const pagesLabel = formatCitationPage(citation);
|
||||
const citationText = [filename, pagesLabel].filter(Boolean).join(", ");
|
||||
const relevantQuotes: CitationQuoteHeaderItem[] = citationQuotes.map(
|
||||
(quote, index) => {
|
||||
const pageLabel = formatCitationQuotePage(
|
||||
citation,
|
||||
quote.page,
|
||||
quote,
|
||||
);
|
||||
return {
|
||||
id: `document:${citation.ref}:${index}`,
|
||||
quote: cleanCitationQuoteText(citation, quote.quote),
|
||||
inlineDetail: pageLabel || null,
|
||||
citationText: [filename, pageLabel].filter(Boolean).join(", "),
|
||||
};
|
||||
},
|
||||
);
|
||||
const currentIndex = Math.max(
|
||||
0,
|
||||
relevantQuotes.findIndex((quote) => quote.id === activeQuoteId),
|
||||
);
|
||||
|
||||
return (
|
||||
<CitationQuotesHeader
|
||||
quotes={relevantQuotes}
|
||||
activeQuoteId={activeQuoteId}
|
||||
currentIndex={currentIndex}
|
||||
citationRef={citation.ref}
|
||||
citationText={citationText}
|
||||
onSelect={(quote) => onQuoteSelect(quote.id)}
|
||||
onIndexChange={(index) => {
|
||||
const quote = relevantQuotes[index];
|
||||
if (quote) onQuoteSelect(quote.id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Download button
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DownloadButton({
|
||||
documentId,
|
||||
versionId,
|
||||
filename,
|
||||
isReloading,
|
||||
}: {
|
||||
documentId: string;
|
||||
versionId: string | null;
|
||||
filename: string;
|
||||
isReloading?: boolean;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
if (busy || isReloading) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
const apiBase =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001";
|
||||
const qs = versionId
|
||||
? `?version_id=${encodeURIComponent(versionId)}`
|
||||
: "";
|
||||
const resp = await fetch(
|
||||
`${apiBase}/single-documents/${documentId}/docx${qs}`,
|
||||
{
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
},
|
||||
);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const blob = await resp.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = blobUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const spinning = busy || isReloading;
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={spinning}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{spinning ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Download
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { PillButton } from "@/components/ui/pill-button";
|
||||
import { supabase } from "@/app/lib/supabase";
|
||||
import { PillButton } from "@/app/components/ui/pill-button";
|
||||
import type { EditAnnotation } from "../shared/types";
|
||||
|
||||
function normalizeText(s: string) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import { MikeIcon } from "@/components/chat/mike-icon";
|
||||
import { useAuth } from "@/app/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/app/contexts/UserProfileContext";
|
||||
import { MikeIcon } from "@/app/components/chat/mike-icon";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import { SelectAssistantProjectModal } from "./SelectAssistantProjectModal";
|
||||
import type { Message } from "../shared/types";
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
} from "@/app/components/ui/dropdown-menu";
|
||||
import { isModelAvailable } from "@/app/lib/modelAvailability";
|
||||
import type { ApiKeyState } from "@/app/lib/mikeApi";
|
||||
|
||||
|
|
|
|||
80
frontend/src/app/components/assistant/PreResponseWrapper.tsx
Normal file
80
frontend/src/app/components/assistant/PreResponseWrapper.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export function PreResponseWrapper({
|
||||
children,
|
||||
stepCount,
|
||||
shouldMinimize,
|
||||
isStreaming,
|
||||
compact = false,
|
||||
forceOpen = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
stepCount: number;
|
||||
shouldMinimize: boolean;
|
||||
isStreaming: boolean;
|
||||
/** Tighter typography + child gap for narrow side panels (e.g. TR chat). */
|
||||
compact?: boolean;
|
||||
forceOpen?: boolean;
|
||||
}) {
|
||||
const [userToggled, setUserToggled] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(!shouldMinimize);
|
||||
// Once content has streamed in (shouldMinimize=true even once), stay
|
||||
// minimized even if a later render briefly evaluates shouldMinimize=false.
|
||||
// Without this latch, the wrapper visibly pops open when isStreaming
|
||||
// flips off at the end of the response.
|
||||
const hasMinimizedRef = useRef(shouldMinimize);
|
||||
|
||||
useEffect(() => {
|
||||
if (forceOpen) {
|
||||
setIsOpen(true);
|
||||
return;
|
||||
}
|
||||
if (shouldMinimize) hasMinimizedRef.current = true;
|
||||
if (userToggled) return;
|
||||
setIsOpen(!shouldMinimize && !hasMinimizedRef.current);
|
||||
}, [forceOpen, shouldMinimize, userToggled]);
|
||||
|
||||
const stepWord = `step${stepCount === 1 ? "" : "s"}`;
|
||||
const label = isStreaming
|
||||
? "Working"
|
||||
: `Completed in ${stepCount} ${stepWord}`;
|
||||
|
||||
const buttonTextClass = compact ? "text-xs" : "text-sm";
|
||||
const childrenGapClass = compact ? "gap-2.5" : "gap-4";
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/70 bg-white/55 px-3 py-2 shadow-[0_3px_9px_rgba(15,23,42,0.03),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-2xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUserToggled(true);
|
||||
setIsOpen((v) => !v);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between font-serif text-gray-500 hover:text-gray-700 transition-colors ${buttonTextClass}`}
|
||||
>
|
||||
<span className="flex items-baseline min-w-0">
|
||||
<span className="truncate">{label}</span>
|
||||
{isStreaming && (
|
||||
<span className="inline-flex ml-1 shrink-0 items-baseline">
|
||||
<span className="w-0.5 h-0.5 rounded-full bg-gray-400 mr-0.5 animate-[bounce_1.4s_infinite_0s]" />
|
||||
<span className="w-0.5 h-0.5 rounded-full bg-gray-400 mr-0.5 animate-[bounce_1.4s_infinite_0.2s]" />
|
||||
<span className="w-0.5 h-0.5 rounded-full bg-gray-400 animate-[bounce_1.4s_infinite_0.4s]" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className={`relative top-px shrink-0 ml-2 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className={`mt-3 flex flex-col ${childrenGapClass}`}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
229
frontend/src/app/components/assistant/TrackedChangeHeader.tsx
Normal file
229
frontend/src/app/components/assistant/TrackedChangeHeader.tsx
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { supabase } from "@/app/lib/supabase";
|
||||
import { PillButton } from "@/app/components/ui/pill-button";
|
||||
import { applyOptimisticResolution } from "./EditCard";
|
||||
import type { EditAnnotation } from "../shared/types";
|
||||
|
||||
const PANEL_GLASS_SURFACE =
|
||||
"rounded-2xl bg-white/58 shadow-[0_5px_15px_rgba(15,23,42,0.095),inset_0_1px_0_rgba(255,255,255,0.88),inset_0_-8px_16px_rgba(255,255,255,0.16)] backdrop-blur-2xl";
|
||||
const PANEL_CARD_SURFACE = "rounded-lg bg-gray-100";
|
||||
|
||||
type ResolveArgs = {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
verb: "accept" | "reject";
|
||||
};
|
||||
|
||||
type ResolvedArgs = {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
status: "accepted" | "rejected";
|
||||
versionId: string | null;
|
||||
downloadUrl: string | null;
|
||||
};
|
||||
|
||||
type ErrorArgs = {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
versionId: string | null;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function TrackedChangeHeader({
|
||||
edit,
|
||||
changeNumber,
|
||||
isEditReloading,
|
||||
onResolveStart,
|
||||
onResolved,
|
||||
onError,
|
||||
onHighlight,
|
||||
}: {
|
||||
edit: EditAnnotation;
|
||||
changeNumber?: number;
|
||||
isEditReloading?: boolean;
|
||||
onResolveStart?: (args: ResolveArgs) => void;
|
||||
onResolved?: (args: ResolvedArgs) => void;
|
||||
onError?: (args: ErrorArgs) => void;
|
||||
onHighlight?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-3 pb-3">
|
||||
<div className={`${PANEL_GLASS_SURFACE} px-2 py-2`}>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<p className="text-xs font-medium text-gray-700">
|
||||
Tracked Change
|
||||
{changeNumber !== undefined ? ` ${changeNumber}` : ""}
|
||||
</p>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
<EditResolveButtons
|
||||
edit={edit}
|
||||
isReloading={isEditReloading}
|
||||
onResolveStart={onResolveStart}
|
||||
onResolved={onResolved}
|
||||
onError={onError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{edit.reason && (
|
||||
<p className="mb-3 text-xs text-gray-500">{edit.reason}</p>
|
||||
)}
|
||||
<div
|
||||
className={`w-full px-3 py-2.5 text-left transition-colors ${PANEL_CARD_SURFACE} ${
|
||||
onHighlight ? "cursor-pointer hover:bg-gray-200/70" : ""
|
||||
}`}
|
||||
role={onHighlight ? "button" : undefined}
|
||||
tabIndex={onHighlight ? 0 : undefined}
|
||||
onClick={onHighlight}
|
||||
onKeyDown={(event) => {
|
||||
if (!onHighlight) return;
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
onHighlight();
|
||||
}}
|
||||
>
|
||||
<div className="font-serif text-sm leading-relaxed">
|
||||
{edit.inserted_text && (
|
||||
<span className="text-green-700">
|
||||
{edit.inserted_text}
|
||||
</span>
|
||||
)}
|
||||
{edit.deleted_text && (
|
||||
<span className="text-red-600 line-through">
|
||||
{edit.deleted_text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditResolveButtons({
|
||||
edit,
|
||||
isReloading,
|
||||
onResolveStart,
|
||||
onResolved,
|
||||
onError,
|
||||
}: {
|
||||
edit: EditAnnotation;
|
||||
isReloading?: boolean;
|
||||
onResolveStart?: (args: ResolveArgs) => void;
|
||||
onResolved?: (args: ResolvedArgs) => void;
|
||||
onError?: (args: ErrorArgs) => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState<"pending" | "accepted" | "rejected">(
|
||||
edit.status,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (busy) return;
|
||||
setStatus(edit.status);
|
||||
}, [edit.status, edit.edit_id, busy]);
|
||||
|
||||
const resolved = status !== "pending";
|
||||
|
||||
const handle = useCallback(
|
||||
async (verb: "accept" | "reject") => {
|
||||
if (busy || resolved) return;
|
||||
setBusy(true);
|
||||
onResolveStart?.({
|
||||
editId: edit.edit_id,
|
||||
documentId: edit.document_id,
|
||||
verb,
|
||||
});
|
||||
let revert: (() => void) | null = null;
|
||||
try {
|
||||
revert = applyOptimisticResolution(edit, verb);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"[TrackedChangeHeader] optimistic update threw",
|
||||
e,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
const apiBase =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ??
|
||||
"http://localhost:3001";
|
||||
const resp = await fetch(
|
||||
`${apiBase}/single-documents/${edit.document_id}/edits/${edit.edit_id}/${verb}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: token
|
||||
? { Authorization: `Bearer ${token}` }
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = (await resp.json()) as {
|
||||
ok: boolean;
|
||||
status?: "accepted" | "rejected";
|
||||
version_id: string | null;
|
||||
download_url: string | null;
|
||||
};
|
||||
const nextStatus =
|
||||
data.status ??
|
||||
(verb === "accept" ? "accepted" : "rejected");
|
||||
setStatus(nextStatus);
|
||||
onResolved?.({
|
||||
editId: edit.edit_id,
|
||||
documentId: edit.document_id,
|
||||
status: nextStatus,
|
||||
versionId: data.version_id,
|
||||
downloadUrl: data.download_url,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[TrackedChangeHeader] resolve failed", e);
|
||||
try {
|
||||
revert?.();
|
||||
} catch (revertErr) {
|
||||
console.error(
|
||||
"[TrackedChangeHeader] revert threw",
|
||||
revertErr,
|
||||
);
|
||||
}
|
||||
onError?.({
|
||||
editId: edit.edit_id,
|
||||
documentId: edit.document_id,
|
||||
versionId: edit.version_id ?? null,
|
||||
message:
|
||||
verb === "accept"
|
||||
? "Couldn't save accept — please retry."
|
||||
: "Couldn't save reject — please retry.",
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[busy, resolved, edit, onResolveStart, onResolved, onError],
|
||||
);
|
||||
|
||||
const inFlight = busy || !!isReloading;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<PillButton
|
||||
tone="black"
|
||||
size="sm"
|
||||
onClick={() => handle("accept")}
|
||||
disabled={inFlight || resolved}
|
||||
>
|
||||
{status === "accepted" ? "Accepted" : "Accept"}
|
||||
</PillButton>
|
||||
<PillButton
|
||||
tone="white"
|
||||
size="sm"
|
||||
onClick={() => handle("reject")}
|
||||
disabled={inFlight || resolved}
|
||||
>
|
||||
{status === "rejected" ? "Rejected" : "Reject"}
|
||||
</PillButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
import { Loader2, Scale } from "lucide-react";
|
||||
import { FileTypeIcon } from "../../shared/FileTypeIcon";
|
||||
import { displayCitationQuote, formatCitationPage } from "../../shared/types";
|
||||
import type { Citation } from "../../shared/types";
|
||||
import { RESPONSE_GLASS_ANNOTATION, RESPONSE_GLASS_SURFACE } from "./messageStyles";
|
||||
|
||||
type CitationSourceRow = {
|
||||
key: string;
|
||||
label: string;
|
||||
source: Citation;
|
||||
entries: { annotation: Citation; index: number }[];
|
||||
};
|
||||
|
||||
function citationSourceKey(annotation: Citation): string {
|
||||
if (annotation.kind === "case") {
|
||||
return `case:${annotation.cluster_id}`;
|
||||
}
|
||||
return `document:${annotation.document_id}`;
|
||||
}
|
||||
|
||||
function citationSourceLabel(annotation: Citation): string {
|
||||
if (annotation.kind === "case") {
|
||||
const caseName = annotation.case_name?.trim();
|
||||
const citation = annotation.citation?.trim();
|
||||
if (caseName && citation) return `${caseName}, ${citation}`;
|
||||
return caseName || citation || `Case ${annotation.cluster_id}`;
|
||||
}
|
||||
return annotation.filename;
|
||||
}
|
||||
|
||||
export function citationTooltip(annotation: Citation): string {
|
||||
const locator = formatCitationPage(annotation);
|
||||
const quote = displayCitationQuote(annotation);
|
||||
return locator ? `${locator}: "${quote}"` : `"${quote}"`;
|
||||
}
|
||||
|
||||
function CitationSourceIcon({
|
||||
annotation,
|
||||
}: {
|
||||
annotation: Citation;
|
||||
}) {
|
||||
if (annotation.kind === "case") {
|
||||
return <Scale className="h-3.5 w-3.5 text-slate-600" />;
|
||||
}
|
||||
return (
|
||||
<FileTypeIcon fileType={annotation.filename} className="h-3.5 w-3.5" />
|
||||
);
|
||||
}
|
||||
|
||||
function buildCitationSourceRows(
|
||||
citations: Citation[],
|
||||
): CitationSourceRow[] {
|
||||
const rows = new Map<string, CitationSourceRow>();
|
||||
citations.forEach((annotation, index) => {
|
||||
const key = citationSourceKey(annotation);
|
||||
const existing = rows.get(key);
|
||||
if (existing) {
|
||||
existing.entries.push({ annotation, index });
|
||||
return;
|
||||
}
|
||||
rows.set(key, {
|
||||
key,
|
||||
label: citationSourceLabel(annotation),
|
||||
source: annotation,
|
||||
entries: [{ annotation, index }],
|
||||
});
|
||||
});
|
||||
return Array.from(rows.values());
|
||||
}
|
||||
|
||||
function escapeHtmlText(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function ensureTerminalPeriod(value: string): string {
|
||||
return /[.!?]$/.test(value.trim()) ? value.trim() : `${value.trim()}.`;
|
||||
}
|
||||
|
||||
export function buildCitationAppendix(citations: Citation[]) {
|
||||
if (citations.length === 0) return { html: "", text: "" };
|
||||
let previousSourceKey: string | null = null;
|
||||
const entries = citations.map((annotation) => {
|
||||
const sourceKey = citationSourceKey(annotation);
|
||||
const label =
|
||||
sourceKey === previousSourceKey
|
||||
? "Id."
|
||||
: citationSourceLabel(annotation);
|
||||
previousSourceKey = sourceKey;
|
||||
return {
|
||||
number: annotation.ref,
|
||||
label,
|
||||
quote: displayCitationQuote(annotation).trim(),
|
||||
};
|
||||
});
|
||||
const textLines = [
|
||||
"",
|
||||
"Citations",
|
||||
...entries.map((entry) => {
|
||||
const quote = entry.quote ? ` "${entry.quote}"` : "";
|
||||
return `${entry.number} ${ensureTerminalPeriod(entry.label)}${quote}`;
|
||||
}),
|
||||
];
|
||||
const html = [
|
||||
`<section class="copied-citations">`,
|
||||
`<h3>Citations</h3>`,
|
||||
...entries.map((entry) => {
|
||||
const label = escapeHtmlText(ensureTerminalPeriod(entry.label));
|
||||
const quote = entry.quote
|
||||
? ` "${escapeHtmlText(entry.quote)}"`
|
||||
: "";
|
||||
return `<p><sup>${entry.number}</sup> ${label}${quote}</p>`;
|
||||
}),
|
||||
`</section>`,
|
||||
].join("");
|
||||
return { html, text: textLines.join("\n") };
|
||||
}
|
||||
|
||||
export function CitationsBlock({
|
||||
citations,
|
||||
onCitationClick,
|
||||
onOpenSource,
|
||||
canOpenSource,
|
||||
showWhenEmpty = false,
|
||||
isLoading = false,
|
||||
}: {
|
||||
citations: Citation[];
|
||||
onCitationClick?: (citation: Citation) => void;
|
||||
onOpenSource?: (citation: Citation) => void;
|
||||
canOpenSource?: (citation: Citation) => boolean;
|
||||
showWhenEmpty?: boolean;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const rows = buildCitationSourceRows(citations);
|
||||
if (rows.length === 0 && !showWhenEmpty) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-2 mb-3">
|
||||
<div className={`overflow-hidden ${RESPONSE_GLASS_SURFACE}`}>
|
||||
<div className="flex items-center justify-between gap-3 bg-white/25 px-3 py-2">
|
||||
<h3 className="text-base font-serif text-gray-900">
|
||||
Citations
|
||||
</h3>
|
||||
{isLoading && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{rows.map((row) => {
|
||||
const sourceIsClickable =
|
||||
!!onOpenSource &&
|
||||
(canOpenSource?.(row.source) ?? true);
|
||||
return (
|
||||
<div
|
||||
key={row.key}
|
||||
className="flex items-center gap-3 px-3 py-3"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenSource?.(row.source)}
|
||||
disabled={!sourceIsClickable}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded-lg text-left text-sm font-serif text-gray-700 transition-colors enabled:hover:text-gray-950 disabled:cursor-default"
|
||||
>
|
||||
<CitationSourceIcon
|
||||
annotation={row.source}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{row.label}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex shrink-0 flex-wrap justify-end gap-1">
|
||||
{row.entries.map(
|
||||
({ annotation, index }) => (
|
||||
<button
|
||||
key={`${row.key}:${index}`}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onCitationClick?.(
|
||||
annotation,
|
||||
)
|
||||
}
|
||||
className={
|
||||
RESPONSE_GLASS_ANNOTATION
|
||||
}
|
||||
title={citationTooltip(
|
||||
annotation,
|
||||
)}
|
||||
>
|
||||
{annotation.ref}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
import { useState, type ReactNode } from "react";
|
||||
import { ChevronDown, Loader2 } from "lucide-react";
|
||||
import { PillButton } from "@/app/components/ui/pill-button";
|
||||
import { supabase } from "@/app/lib/supabase";
|
||||
import type { EditAnnotation } from "../../shared/types";
|
||||
import { applyOptimisticResolution } from "../EditCard";
|
||||
|
||||
/**
|
||||
* Card rendered above the per-edit EditCards when a message produced
|
||||
* multiple tracked-change proposals. Lets the user resolve every pending
|
||||
* edit in one click by firing the per-edit accept/reject endpoint for each
|
||||
* pending annotation and forwarding each response to `onResolved` so the
|
||||
* parent can bump the viewer version, persist override URLs, etc.
|
||||
*
|
||||
* This intentionally doesn't apply the optimistic DOM mutation that
|
||||
* EditCard does — bulk operations touch many edits at once and the real
|
||||
* re-render from the latest version will reconcile within a second or so.
|
||||
*/
|
||||
function BulkEditActions({
|
||||
pending,
|
||||
onViewClick,
|
||||
onResolveStart,
|
||||
onResolved,
|
||||
onError,
|
||||
}: {
|
||||
pending: {
|
||||
annotation: EditAnnotation;
|
||||
filename: string;
|
||||
}[];
|
||||
onViewClick?: (ann: EditAnnotation, filename: string) => void;
|
||||
onResolveStart?: (args: {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
verb: "accept" | "reject";
|
||||
}) => void;
|
||||
onResolved?: (args: {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
status: "accepted" | "rejected";
|
||||
versionId: string | null;
|
||||
downloadUrl: string | null;
|
||||
}) => void;
|
||||
onError?: (args: {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
versionId: string | null;
|
||||
message: string;
|
||||
}) => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState<"accept" | "reject" | null>(null);
|
||||
const [progress, setProgress] = useState<{
|
||||
done: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
|
||||
if (pending.length === 0) return null;
|
||||
|
||||
const handleAll = async (verb: "accept" | "reject") => {
|
||||
if (busy) return;
|
||||
setBusy(verb);
|
||||
setProgress({ done: 0, total: pending.length });
|
||||
try {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
const apiBase =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001";
|
||||
|
||||
// Sequential so the per-document version counter advances in a
|
||||
// predictable order and the viewer doesn't race between bumps.
|
||||
let done = 0;
|
||||
for (const { annotation } of pending) {
|
||||
onResolveStart?.({
|
||||
editId: annotation.edit_id,
|
||||
documentId: annotation.document_id,
|
||||
verb,
|
||||
});
|
||||
// Optimistically mutate the DOM so the viewer reflects the
|
||||
// resolution immediately. Revert if the backend call fails.
|
||||
let revert: (() => void) | null = null;
|
||||
try {
|
||||
revert = applyOptimisticResolution(annotation, verb);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"[BulkEditActions] optimistic update threw",
|
||||
e,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${apiBase}/single-documents/${annotation.document_id}/edits/${annotation.edit_id}/${verb}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: token
|
||||
? { Authorization: `Bearer ${token}` }
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = (await resp.json()) as {
|
||||
ok: boolean;
|
||||
status?: "accepted" | "rejected";
|
||||
version_id: string | null;
|
||||
download_url: string | null;
|
||||
};
|
||||
const nextStatus =
|
||||
data.status ??
|
||||
(verb === "accept" ? "accepted" : "rejected");
|
||||
onResolved?.({
|
||||
editId: annotation.edit_id,
|
||||
documentId: annotation.document_id,
|
||||
status: nextStatus,
|
||||
versionId: data.version_id,
|
||||
downloadUrl: data.download_url,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[BulkEditActions] resolve failed", e);
|
||||
try {
|
||||
revert?.();
|
||||
} catch (revertErr) {
|
||||
console.error(
|
||||
"[BulkEditActions] revert threw",
|
||||
revertErr,
|
||||
);
|
||||
}
|
||||
onError?.({
|
||||
editId: annotation.edit_id,
|
||||
documentId: annotation.document_id,
|
||||
versionId: annotation.version_id ?? null,
|
||||
message:
|
||||
verb === "accept"
|
||||
? "Couldn't save one or more accepts."
|
||||
: "Couldn't save one or more rejects.",
|
||||
});
|
||||
}
|
||||
done++;
|
||||
setProgress({ done, total: pending.length });
|
||||
}
|
||||
} finally {
|
||||
setBusy(null);
|
||||
setProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Optional: show a tiny "View first" action so bulk doesn't lose the
|
||||
// in-viewer scroll-to behaviour entirely.
|
||||
const first = pending[0];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<PillButton
|
||||
tone="black"
|
||||
size="sm"
|
||||
onClick={() => handleAll("accept")}
|
||||
disabled={!!busy}
|
||||
>
|
||||
{busy === "accept" && (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
)}
|
||||
Accept all
|
||||
</PillButton>
|
||||
<PillButton
|
||||
tone="white"
|
||||
size="sm"
|
||||
onClick={() => handleAll("reject")}
|
||||
disabled={!!busy}
|
||||
>
|
||||
{busy === "reject" && (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
)}
|
||||
Reject all
|
||||
</PillButton>
|
||||
{progress && (
|
||||
<span className="text-xs font-serif text-gray-500">
|
||||
{progress.done}/{progress.total}
|
||||
</span>
|
||||
)}
|
||||
{onViewClick && first && (
|
||||
<PillButton
|
||||
tone="blue"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onViewClick(first.annotation, first.filename)
|
||||
}
|
||||
disabled={!!busy}
|
||||
className="ml-auto"
|
||||
>
|
||||
View
|
||||
</PillButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the bulk accept/reject card and the per-edit EditCards in a single
|
||||
* minimisable container. The bulk actions and summary stay visible in the
|
||||
* header; the individual cards collapse via the chevron toggle.
|
||||
*/
|
||||
export function EditCardsSection({
|
||||
pending,
|
||||
filenameByDocId,
|
||||
cards,
|
||||
resolvedCount,
|
||||
onViewClick,
|
||||
onResolveStart,
|
||||
onResolved,
|
||||
onError,
|
||||
}: {
|
||||
pending: {
|
||||
annotation: EditAnnotation;
|
||||
filename: string;
|
||||
}[];
|
||||
filenameByDocId: Map<string, string>;
|
||||
cards: ReactNode[];
|
||||
resolvedCount: number;
|
||||
onViewClick?: (ann: EditAnnotation, filename: string) => void;
|
||||
onResolveStart?: (args: {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
verb: "accept" | "reject";
|
||||
}) => void;
|
||||
onResolved?: (args: {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
status: "accepted" | "rejected";
|
||||
versionId: string | null;
|
||||
downloadUrl: string | null;
|
||||
}) => void;
|
||||
onError?: (args: {
|
||||
editId: string;
|
||||
documentId: string;
|
||||
versionId: string | null;
|
||||
message: string;
|
||||
}) => void;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
if (cards.length === 0) return null;
|
||||
|
||||
const docCount = filenameByDocId.size;
|
||||
const summary =
|
||||
pending.length > 0
|
||||
? docCount > 1
|
||||
? `${pending.length} tracked changes across ${docCount} documents`
|
||||
: `${pending.length} tracked ${pending.length === 1 ? "change" : "changes"}`
|
||||
: docCount > 1
|
||||
? `${resolvedCount} resolved tracked changes across ${docCount} documents`
|
||||
: `${resolvedCount} resolved tracked ${resolvedCount === 1 ? "change" : "changes"}`;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl bg-white shadow-[0_3px_9px_rgba(15,23,42,0.03),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-2xl overflow-hidden">
|
||||
{/* Row 1: summary + chevron */}
|
||||
<div className="flex items-center gap-2 px-3 pt-3">
|
||||
<p className="flex-1 min-w-0 text-sm font-serif text-gray-700 truncate">
|
||||
{summary}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setIsOpen((v) => !v)}
|
||||
aria-label={isOpen ? "Collapse edits" : "Expand edits"}
|
||||
className="shrink-0 rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-800 transition-colors"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/* Row 2: bulk action buttons */}
|
||||
{pending.length > 0 && (
|
||||
<div className="px-3 pt-3">
|
||||
<BulkEditActions
|
||||
pending={pending}
|
||||
onViewClick={onViewClick}
|
||||
onResolveStart={onResolveStart}
|
||||
onResolved={onResolved}
|
||||
onError={onError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Row 3: collapsible cards list */}
|
||||
{isOpen && (
|
||||
<div className="flex flex-col gap-2 px-3 pb-3 pt-3">
|
||||
{cards}
|
||||
</div>
|
||||
)}
|
||||
{!isOpen && <div className="pb-3" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
701
frontend/src/app/components/assistant/message/EventBlocks.tsx
Normal file
701
frontend/src/app/components/assistant/message/EventBlocks.tsx
Normal file
|
|
@ -0,0 +1,701 @@
|
|||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { ChevronDown, Download, Loader2 } from "lucide-react";
|
||||
import { supabase } from "@/app/lib/supabase";
|
||||
import type { AssistantEvent } from "../../shared/types";
|
||||
import { FileTypeIcon } from "../../shared/FileTypeIcon";
|
||||
import { RESPONSE_GLASS_SURFACE, withoutMarkdownNode } from "./messageStyles";
|
||||
|
||||
const THINKING_PHRASES = [
|
||||
"Thinking...",
|
||||
"Pondering...",
|
||||
"Analyzing...",
|
||||
"Reviewing...",
|
||||
"Reasoning...",
|
||||
];
|
||||
const REASONING_COLLAPSED_MAX_LINES = 6;
|
||||
const REASONING_COLLAPSED_MAX_HEIGHT_REM = 9;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event block primitives
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EventConnector() {
|
||||
return (
|
||||
<div className="absolute w-[1px] bg-gray-300 top-[14px] left-[3px] translate-x-[-50%] h-[calc(100%+10px)]" />
|
||||
);
|
||||
}
|
||||
|
||||
export function EventBlock({
|
||||
showConnector,
|
||||
isStreaming,
|
||||
dotColor = "green",
|
||||
children,
|
||||
}: {
|
||||
showConnector?: boolean;
|
||||
isStreaming?: boolean;
|
||||
dotColor?: "green" | "gray" | "red";
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const dotColorClass =
|
||||
dotColor === "green"
|
||||
? "bg-green-400 shadow-[0_1px_3px_rgba(15,23,42,0.15),inset_0_1px_0_rgba(255,255,255,0.5)]"
|
||||
: dotColor === "red"
|
||||
? "bg-red-400 shadow-[0_1px_3px_rgba(15,23,42,0.15),inset_0_1px_0_rgba(255,255,255,0.5)]"
|
||||
: "bg-gray-300 shadow-[0_1px_3px_rgba(15,23,42,0.15),inset_0_1px_0_rgba(255,255,255,0.35)]";
|
||||
return (
|
||||
<div className="flex items-start text-sm font-serif text-gray-500 relative">
|
||||
{showConnector && <EventConnector />}
|
||||
{isStreaming ? (
|
||||
<div className="mt-2 w-1.5 h-1.5 shrink-0 rounded-full border border-gray-400 border-t-transparent animate-spin" />
|
||||
) : (
|
||||
<div
|
||||
className={`mt-2 w-1.5 h-1.5 shrink-0 rounded-full ${dotColorClass}`}
|
||||
/>
|
||||
)}
|
||||
<div className="ml-2 min-w-0 flex-1 whitespace-normal break-words">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ReasoningBlock({
|
||||
text,
|
||||
isStreaming,
|
||||
showConnector,
|
||||
}: {
|
||||
text: string;
|
||||
isStreaming: boolean;
|
||||
showConnector?: boolean;
|
||||
}) {
|
||||
const [isContentOpen, setIsContentOpen] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [userToggledContent, setUserToggledContent] = useState(false);
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
const [hasMeasured, setHasMeasured] = useState(false);
|
||||
const [thinkingIndex, setThinkingIndex] = useState(0);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStreaming) return;
|
||||
const interval = setInterval(() => {
|
||||
setThinkingIndex((i) => (i + 1) % THINKING_PHRASES.length);
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isStreaming]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 24;
|
||||
const maxHeight = lineHeight * REASONING_COLLAPSED_MAX_LINES;
|
||||
const nextOverflowing = el.scrollHeight > maxHeight + 2;
|
||||
setIsOverflowing(nextOverflowing);
|
||||
setHasMeasured(true);
|
||||
if (!userToggledContent) setIsContentOpen(isStreaming);
|
||||
if (!nextOverflowing) setIsExpanded(false);
|
||||
}, [isStreaming, text, userToggledContent]);
|
||||
|
||||
const showContent = isContentOpen || isStreaming || !hasMeasured;
|
||||
const isCollapsed = isContentOpen && isOverflowing && !isExpanded;
|
||||
|
||||
return (
|
||||
<EventBlock
|
||||
showConnector={showConnector}
|
||||
isStreaming={isStreaming}
|
||||
dotColor="gray"
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isStreaming) return;
|
||||
setUserToggledContent(true);
|
||||
setIsContentOpen((v) => !v);
|
||||
}}
|
||||
className="flex items-center text-sm font-serif text-gray-500 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<span className="font-medium">
|
||||
{isStreaming
|
||||
? THINKING_PHRASES[thinkingIndex]
|
||||
: "Thought process"}
|
||||
</span>
|
||||
{!isStreaming && (
|
||||
<ChevronDown
|
||||
size={10}
|
||||
className={`relative top-px ml-1 transition-transform duration-200 ${isContentOpen ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{showContent && (
|
||||
<div className="mt-2">
|
||||
<div
|
||||
className={`relative ${isCollapsed ? "overflow-hidden" : ""}`}
|
||||
style={
|
||||
isCollapsed
|
||||
? {
|
||||
maxHeight: `${REASONING_COLLAPSED_MAX_HEIGHT_REM}rem`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="text-sm font-serif text-gray-400 prose prose-sm max-w-none [&>*]:text-gray-400 [&>*]:text-sm"
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code: (props) => (
|
||||
<code
|
||||
className="font-serif text-gray-600"
|
||||
{...withoutMarkdownNode(props)}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
{isCollapsed && (
|
||||
<>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-b from-white/0 to-white" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
className="absolute left-1/2 bottom-2 z-10 -translate-x-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
||||
aria-label="Expand thought process"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isOverflowing && isContentOpen && isExpanded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
className="mx-auto mt-2 flex text-gray-400 transition-colors hover:text-gray-600"
|
||||
aria-label="Minimise thought process"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5 rotate-180" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</EventBlock>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocReadBlock({
|
||||
filename,
|
||||
onClick,
|
||||
showConnector,
|
||||
isStreaming,
|
||||
}: {
|
||||
filename: string;
|
||||
onClick?: () => void;
|
||||
showConnector?: boolean;
|
||||
isStreaming?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<EventBlock
|
||||
showConnector={showConnector}
|
||||
isStreaming={isStreaming}
|
||||
dotColor="green"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="shrink-0 font-medium">
|
||||
{isStreaming ? "Reading" : "Read"}
|
||||
</span>
|
||||
{isStreaming ? (
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<FileTypeIcon
|
||||
fileType={filename}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="truncate">{filename}...</span>
|
||||
</span>
|
||||
) : onClick ? (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex min-w-0 items-center gap-1.5 text-left transition-colors hover:text-gray-700 cursor-pointer"
|
||||
>
|
||||
<FileTypeIcon
|
||||
fileType={filename}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="truncate">{filename}</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<FileTypeIcon
|
||||
fileType={filename}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="truncate">{filename}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</EventBlock>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocFindBlock({
|
||||
filename,
|
||||
query,
|
||||
totalMatches,
|
||||
isStreaming,
|
||||
showConnector,
|
||||
}: {
|
||||
filename: string;
|
||||
query: string;
|
||||
totalMatches: number;
|
||||
isStreaming?: boolean;
|
||||
showConnector?: boolean;
|
||||
}) {
|
||||
const matchSuffix = isStreaming
|
||||
? ""
|
||||
: ` (${totalMatches} ${totalMatches === 1 ? "match" : "matches"})`;
|
||||
return (
|
||||
<EventBlock
|
||||
showConnector={showConnector}
|
||||
isStreaming={isStreaming}
|
||||
dotColor={totalMatches > 0 ? "green" : "gray"}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{isStreaming ? "Finding" : "Found"}
|
||||
</span>{" "}
|
||||
<span>
|
||||
“{query}”{matchSuffix}
|
||||
<span className="ml-1 text-gray-400">in {filename}</span>
|
||||
{isStreaming && "..."}
|
||||
</span>
|
||||
</EventBlock>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocCreatedBlock({
|
||||
filename,
|
||||
showConnector,
|
||||
isStreaming,
|
||||
}: {
|
||||
filename: string;
|
||||
showConnector?: boolean;
|
||||
isStreaming?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<EventBlock
|
||||
showConnector={showConnector}
|
||||
isStreaming={isStreaming}
|
||||
dotColor="green"
|
||||
>
|
||||
<span className="font-medium">
|
||||
{isStreaming ? "Creating" : "Created"}
|
||||
</span>{" "}
|
||||
<span>{isStreaming ? `${filename}...` : filename}</span>
|
||||
</EventBlock>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocReplicatedBlock({
|
||||
filename,
|
||||
count,
|
||||
showConnector,
|
||||
isStreaming,
|
||||
hasError,
|
||||
}: {
|
||||
filename: string;
|
||||
/**
|
||||
* How many consecutive replicates of this same source got collapsed
|
||||
* into this block. ≥ 1; only rendered when > 1.
|
||||
*/
|
||||
count: number;
|
||||
showConnector?: boolean;
|
||||
isStreaming?: boolean;
|
||||
hasError?: boolean;
|
||||
}) {
|
||||
const label = isStreaming ? "Replicating" : "Replicated";
|
||||
const suffix =
|
||||
!isStreaming && count > 1
|
||||
? ` ${count} times`
|
||||
: isStreaming
|
||||
? "..."
|
||||
: "";
|
||||
return (
|
||||
<EventBlock
|
||||
showConnector={showConnector}
|
||||
isStreaming={isStreaming}
|
||||
dotColor={hasError ? "red" : "green"}
|
||||
>
|
||||
<span className="font-medium">{label}</span>{" "}
|
||||
<span>
|
||||
{filename}
|
||||
{suffix}
|
||||
</span>
|
||||
</EventBlock>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocDownloadBlock({
|
||||
filename,
|
||||
download_url,
|
||||
onOpen,
|
||||
isReloading = false,
|
||||
versionNumber,
|
||||
}: {
|
||||
filename: string;
|
||||
download_url: string;
|
||||
onOpen?: () => void;
|
||||
isReloading?: boolean;
|
||||
versionNumber?: number | null;
|
||||
}) {
|
||||
const hasVersion =
|
||||
typeof versionNumber === "number" &&
|
||||
Number.isFinite(versionNumber) &&
|
||||
versionNumber > 0;
|
||||
const extMatch = filename.match(/\.(\w+)$/);
|
||||
const ext = extMatch ? extMatch[1].toUpperCase() : "FILE";
|
||||
const rawBasename = extMatch
|
||||
? filename.slice(0, -extMatch[0].length)
|
||||
: filename;
|
||||
// Strip any legacy "[Edited V3]" suffix that may still be baked into
|
||||
// older saved download filenames — the version is surfaced as a
|
||||
// separate tag now.
|
||||
const basename = rawBasename.replace(/\s*\[Edited V\d+\]\s*$/, "").trim();
|
||||
// Only backend-relative URLs are accepted. The download fetch carries
|
||||
// the user's bearer token, so any absolute URL from tool output is
|
||||
// refused to keep the token from leaking off-origin.
|
||||
const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001";
|
||||
const isSafeHref = download_url.startsWith("/");
|
||||
const href = isSafeHref ? `${API_BASE}${download_url}` : null;
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const handleDownload = async (e?: {
|
||||
stopPropagation?: () => void;
|
||||
preventDefault?: () => void;
|
||||
}) => {
|
||||
e?.stopPropagation?.();
|
||||
e?.preventDefault?.();
|
||||
if (busy || isReloading || !href) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
const resp = await fetch(href, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const blob = await resp.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = blobUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const spinning = busy || isReloading;
|
||||
|
||||
const body = (
|
||||
<div className="flex items-center gap-3 px-4 py-3 min-w-0 flex-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<p className="text-base font-serif text-gray-900 text-wrap">
|
||||
{basename}
|
||||
</p>
|
||||
{hasVersion && (
|
||||
<span className="shrink-0 inline-flex items-center rounded-md border border-white/70 bg-white/55 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] backdrop-blur-xl">
|
||||
V{versionNumber}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-blue-500 mt-0.5">{ext}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const downloadIcon = spinning ? (
|
||||
<div
|
||||
aria-disabled
|
||||
className="shrink-0 flex items-center bg-white/25 px-6 text-gray-400 cursor-not-allowed"
|
||||
>
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="shrink-0 flex items-center bg-white/25 px-6 text-gray-500 transition-colors hover:bg-white/55 hover:text-gray-700 cursor-pointer"
|
||||
>
|
||||
<Download size={13} />
|
||||
</button>
|
||||
);
|
||||
|
||||
if (onOpen) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-stretch overflow-hidden w-full font-sans ${RESPONSE_GLASS_SURFACE}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="flex items-stretch flex-1 min-w-0 text-left transition-colors hover:bg-white/45 cursor-pointer"
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
{downloadIcon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (spinning) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-stretch overflow-hidden w-full font-sans ${RESPONSE_GLASS_SURFACE}`}
|
||||
>
|
||||
{body}
|
||||
{downloadIcon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-stretch overflow-hidden w-full font-sans ${RESPONSE_GLASS_SURFACE}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="flex items-stretch flex-1 min-w-0 text-left transition-colors hover:bg-white/45 cursor-pointer"
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
{downloadIcon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowAppliedBlock({
|
||||
title,
|
||||
showConnector,
|
||||
onClick,
|
||||
}: {
|
||||
title: string;
|
||||
showConnector?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<EventBlock showConnector={showConnector} dotColor="green">
|
||||
<span className="font-medium">Applied Workflow</span>{" "}
|
||||
{onClick ? (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="text-left hover:text-gray-700 transition-colors cursor-pointer"
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
) : (
|
||||
<span>{title}</span>
|
||||
)}
|
||||
</EventBlock>
|
||||
);
|
||||
}
|
||||
|
||||
export function AskInputsBlock({
|
||||
event,
|
||||
response,
|
||||
showConnector,
|
||||
}: {
|
||||
event: Extract<AssistantEvent, { type: "ask_inputs" }>;
|
||||
response?: Extract<AssistantEvent, { type: "ask_inputs_response" }>;
|
||||
showConnector?: boolean;
|
||||
}) {
|
||||
const responseById = new Map(
|
||||
response?.responses.map((item) => [item.id, item]) ?? [],
|
||||
);
|
||||
return (
|
||||
<EventBlock
|
||||
showConnector={showConnector}
|
||||
dotColor={response ? "green" : "gray"}
|
||||
>
|
||||
<p className="font-medium text-gray-600">
|
||||
{response ? "Asked for input" : "Asking for input"}
|
||||
</p>
|
||||
<div className="mt-2 space-y-2 text-gray-800">
|
||||
{event.items.map((item, index) => {
|
||||
const itemResponse = responseById.get(item.id);
|
||||
const responseText = (() => {
|
||||
if (!itemResponse) return null;
|
||||
if (itemResponse.skipped) return "Skipped";
|
||||
if (itemResponse.kind === "choice") {
|
||||
return itemResponse.answer ?? "";
|
||||
}
|
||||
const filenames = itemResponse.filenames;
|
||||
return filenames.length
|
||||
? filenames.join(", ")
|
||||
: "No documents attached";
|
||||
})();
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<p className="text-xs text-gray-500">
|
||||
{index + 1}.{" "}
|
||||
{item.kind === "choice"
|
||||
? "Question"
|
||||
: "Documents"}
|
||||
</p>
|
||||
<p className="mt-0.5">
|
||||
{item.kind === "choice"
|
||||
? item.question
|
||||
: item.document_types.join(", ") ||
|
||||
"Documents requested"}
|
||||
</p>
|
||||
{responseText !== null && (
|
||||
<p className="mt-0.5 text-gray-600">
|
||||
{responseText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</EventBlock>
|
||||
);
|
||||
}
|
||||
|
||||
export type CourtListenerBlockItem = {
|
||||
caseName: string | null;
|
||||
citation: string | null;
|
||||
dateFiled?: string | null;
|
||||
url?: string | null;
|
||||
query?: string;
|
||||
totalMatches?: number;
|
||||
hasError?: boolean;
|
||||
};
|
||||
|
||||
export function CourtListenerBlock({
|
||||
label,
|
||||
detail,
|
||||
isStreaming,
|
||||
hasError,
|
||||
showConnector,
|
||||
items,
|
||||
}: {
|
||||
label: string;
|
||||
detail?: string;
|
||||
isStreaming?: boolean;
|
||||
hasError?: boolean;
|
||||
showConnector?: boolean;
|
||||
items?: CourtListenerBlockItem[];
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const hasItems = !!items && items.length > 0;
|
||||
return (
|
||||
<EventBlock
|
||||
showConnector={showConnector}
|
||||
isStreaming={isStreaming}
|
||||
dotColor={hasError ? "red" : "green"}
|
||||
>
|
||||
{hasItems ? (
|
||||
<button
|
||||
onClick={() => setIsOpen((v) => !v)}
|
||||
className="text-left hover:text-gray-700 transition-colors inline-flex items-center"
|
||||
>
|
||||
<span className="font-medium">{label}</span>
|
||||
{detail ? <span> {detail}</span> : null}
|
||||
{isStreaming ? <span>...</span> : null}
|
||||
<ChevronDown
|
||||
size={10}
|
||||
className={`relative top-px ml-1 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-medium">{label}</span>
|
||||
{detail ? <span> {detail}</span> : null}
|
||||
{isStreaming ? <span>...</span> : null}
|
||||
</>
|
||||
)}
|
||||
{isOpen && hasItems && (
|
||||
<ul className="mt-2 flex flex-col gap-1 text-sm font-serif text-gray-500">
|
||||
{items!.map((item, idx) => {
|
||||
const label = [item.caseName, item.citation]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
const primary = label || item.url || "Unknown case";
|
||||
const searchText = item.query
|
||||
? `Searched for "${item.query}" in ${primary}${
|
||||
typeof item.totalMatches === "number"
|
||||
? ` (${item.totalMatches} ${
|
||||
item.totalMatches === 1
|
||||
? "match"
|
||||
: "matches"
|
||||
})`
|
||||
: ""
|
||||
}`
|
||||
: null;
|
||||
return (
|
||||
<li key={idx}>
|
||||
<div
|
||||
className={
|
||||
item.hasError ? "text-red-500" : ""
|
||||
}
|
||||
>
|
||||
{item.url ? (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hover:text-gray-700 hover:underline underline-offset-2"
|
||||
>
|
||||
{searchText ?? primary}
|
||||
</a>
|
||||
) : searchText ? (
|
||||
<span>{searchText}</span>
|
||||
) : (
|
||||
<span>{primary}</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</EventBlock>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocEditedBlock({
|
||||
filename,
|
||||
showConnector,
|
||||
isStreaming,
|
||||
hasError,
|
||||
}: {
|
||||
filename: string;
|
||||
showConnector?: boolean;
|
||||
isStreaming?: boolean;
|
||||
hasError?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<EventBlock
|
||||
showConnector={showConnector}
|
||||
isStreaming={isStreaming}
|
||||
dotColor={hasError ? "red" : "green"}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{isStreaming ? "Editing" : hasError ? "Edit failed" : "Edited"}
|
||||
</span>{" "}
|
||||
<span>{isStreaming ? `${filename}...` : filename}</span>
|
||||
</EventBlock>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
import type { RefObject } from "react";
|
||||
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
|
||||
import remarkMath from "remark-math";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import "katex/dist/katex.min.css";
|
||||
import type { AssistantEvent, Citation } from "../../shared/types";
|
||||
import { RESPONSE_GLASS_ANNOTATION, withoutMarkdownNode } from "./messageStyles";
|
||||
import { citationTooltip } from "./CitationSources";
|
||||
import { internalCaseHref } from "./citationUtils";
|
||||
|
||||
export function MarkdownContent({
|
||||
text,
|
||||
inlineCitationTargets,
|
||||
caseCitations,
|
||||
caseOpinions,
|
||||
onCitationClick,
|
||||
onCaseClick,
|
||||
divRef,
|
||||
}: {
|
||||
text: string;
|
||||
inlineCitationTargets: Citation[];
|
||||
caseCitations: Map<
|
||||
string,
|
||||
Extract<AssistantEvent, { type: "case_citation" }>
|
||||
>;
|
||||
caseOpinions: Map<
|
||||
number,
|
||||
Extract<AssistantEvent, { type: "case_opinions" }>["case"]
|
||||
>;
|
||||
onCitationClick?: (c: Citation) => void;
|
||||
onCaseClick?: (
|
||||
c: Extract<AssistantEvent, { type: "case_citation" }>,
|
||||
) => void;
|
||||
divRef?: RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
function findCaseCitation(href: string) {
|
||||
return caseCitations.get(internalCaseHref(href) ?? "");
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={divRef}
|
||||
className="text-gray-900 mb-4 text-base prose prose-sm max-w-none font-serif"
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[
|
||||
[remarkMath, { singleDollarTextMath: false }],
|
||||
remarkGfm,
|
||||
]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
urlTransform={(url) =>
|
||||
/^us-case-\d+$/.test(url) ? url : defaultUrlTransform(url)
|
||||
}
|
||||
components={{
|
||||
table: (props) => (
|
||||
<div className="overflow-x-auto my-4 rounded-lg">
|
||||
<table
|
||||
className="min-w-full divide-y divide-gray-300 overflow-hidden"
|
||||
{...withoutMarkdownNode(props)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
thead: (props) => (
|
||||
<thead
|
||||
className="bg-gray-100"
|
||||
{...withoutMarkdownNode(props)}
|
||||
/>
|
||||
),
|
||||
tbody: (props) => (
|
||||
<tbody
|
||||
className="divide-y divide-gray-200"
|
||||
{...withoutMarkdownNode(props)}
|
||||
/>
|
||||
),
|
||||
tr: (props) => <tr {...withoutMarkdownNode(props)} />,
|
||||
th: (props) => (
|
||||
<th
|
||||
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
|
||||
{...withoutMarkdownNode(props)}
|
||||
/>
|
||||
),
|
||||
td: (props) => (
|
||||
<td
|
||||
className="whitespace-normal px-3 py-4 text-sm text-gray-900"
|
||||
{...withoutMarkdownNode(props)}
|
||||
/>
|
||||
),
|
||||
h1: (props) => (
|
||||
<h1
|
||||
className="mt-6 mb-4 text-3xl font-serif font-semibold"
|
||||
{...withoutMarkdownNode(props)}
|
||||
/>
|
||||
),
|
||||
h2: (props) => (
|
||||
<h2
|
||||
className="mt-5 mb-3 text-2xl font-serif font-semibold"
|
||||
{...withoutMarkdownNode(props)}
|
||||
/>
|
||||
),
|
||||
h3: (props) => (
|
||||
<h3
|
||||
className="text-xl font-semibold mt-4 mb-2"
|
||||
{...withoutMarkdownNode(props)}
|
||||
/>
|
||||
),
|
||||
h4: (props) => (
|
||||
<h4
|
||||
className="text-lg font-semibold mt-4 mb-2"
|
||||
{...withoutMarkdownNode(props)}
|
||||
/>
|
||||
),
|
||||
p: ({ node, ...props }) => {
|
||||
const parent =
|
||||
node && typeof node === "object" && "parent" in node
|
||||
? (node as { parent?: { type?: string } })
|
||||
.parent
|
||||
: undefined;
|
||||
if (parent?.type === "listItem") {
|
||||
return (
|
||||
<p
|
||||
className="inline leading-7 m-0"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <p className="mb-4 leading-7" {...props} />;
|
||||
},
|
||||
ul: (props) => (
|
||||
<ul
|
||||
className="list-disc list-outside mb-4 pl-6"
|
||||
{...withoutMarkdownNode(props)}
|
||||
/>
|
||||
),
|
||||
ol: (props) => (
|
||||
<ol
|
||||
className="list-decimal list-outside mb-4 pl-6"
|
||||
{...withoutMarkdownNode(props)}
|
||||
/>
|
||||
),
|
||||
li: (props) => (
|
||||
<li
|
||||
className="mb-2 leading-7"
|
||||
{...withoutMarkdownNode(props)}
|
||||
/>
|
||||
),
|
||||
strong: (props) => (
|
||||
<strong
|
||||
className="font-semibold"
|
||||
{...withoutMarkdownNode(props)}
|
||||
/>
|
||||
),
|
||||
em: (props) => (
|
||||
<em className="italic" {...withoutMarkdownNode(props)} />
|
||||
),
|
||||
code: (props) => {
|
||||
const { children, ...codeProps } =
|
||||
withoutMarkdownNode(props);
|
||||
const text = String(children);
|
||||
const citMatch = text.match(/^§(\d+)§$/);
|
||||
if (citMatch) {
|
||||
const idx = parseInt(citMatch[1]);
|
||||
const annotation = inlineCitationTargets[idx];
|
||||
if (annotation) {
|
||||
const tooltipText = citationTooltip(annotation);
|
||||
return (
|
||||
<button
|
||||
onClick={() =>
|
||||
onCitationClick?.(annotation)
|
||||
}
|
||||
data-citation-ref={annotation.ref}
|
||||
className={`${RESPONSE_GLASS_ANNOTATION} mx-0.5 align-super`}
|
||||
title={tooltipText}
|
||||
>
|
||||
{annotation.ref}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<code
|
||||
className="bg-gray-100 px-1.5 py-0.5 rounded text-sm font-serif"
|
||||
{...codeProps}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
blockquote: (props) => (
|
||||
<blockquote
|
||||
className="border-l-4 border-gray-300 pl-4 italic my-4"
|
||||
{...withoutMarkdownNode(props)}
|
||||
/>
|
||||
),
|
||||
a: (props) => {
|
||||
const { href, children, ...anchorProps } =
|
||||
withoutMarkdownNode(props);
|
||||
if (href) {
|
||||
const isInternalCaseHref = !!internalCaseHref(href);
|
||||
const citation = findCaseCitation(href);
|
||||
if (citation && onCaseClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onCaseClick({
|
||||
...citation,
|
||||
case:
|
||||
citation.cluster_id !== null
|
||||
? caseOpinions.get(
|
||||
citation.cluster_id,
|
||||
)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
className="text-left text-blue-600 hover:text-blue-700 underline"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (citation) {
|
||||
return (
|
||||
<a
|
||||
href={citation.url}
|
||||
className="text-blue-600 hover:text-blue-700 underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
if (isInternalCaseHref) {
|
||||
return (
|
||||
<span className="text-blue-600 underline">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="text-blue-600 hover:text-blue-700 underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...anchorProps}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="text-blue-600 hover:text-blue-700 underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...anchorProps}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
hr: (props) => (
|
||||
<hr
|
||||
className="my-6 border-gray-200"
|
||||
{...withoutMarkdownNode(props)}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { MikeIcon } from "@/app/components/chat/mike-icon";
|
||||
|
||||
export type StatusState = "active" | "error" | null;
|
||||
|
||||
export function ResponseStatus({ status }: { status: StatusState }) {
|
||||
const [showDone, setShowDone] = useState(false);
|
||||
const [doneVisible, setDoneVisible] = useState(false);
|
||||
const wasActiveRef = useRef(false);
|
||||
|
||||
const isActive = status === "active";
|
||||
const isError = status === "error";
|
||||
|
||||
useEffect(() => {
|
||||
const wasActive = wasActiveRef.current;
|
||||
wasActiveRef.current = isActive;
|
||||
|
||||
let raf = 0;
|
||||
let doneTimeout = 0;
|
||||
if (wasActive && !isActive) {
|
||||
raf = window.requestAnimationFrame(() => {
|
||||
setShowDone(true);
|
||||
setDoneVisible(true);
|
||||
doneTimeout = window.setTimeout(
|
||||
() => setDoneVisible(false),
|
||||
1500,
|
||||
);
|
||||
});
|
||||
} else if (!wasActive && isActive) {
|
||||
raf = window.requestAnimationFrame(() => {
|
||||
setShowDone(false);
|
||||
setDoneVisible(false);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf);
|
||||
if (doneTimeout) window.clearTimeout(doneTimeout);
|
||||
};
|
||||
}, [isActive]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-9 flex items-center mb-2">
|
||||
<MikeIcon
|
||||
spin={isActive}
|
||||
done={showDone && doneVisible}
|
||||
error={isError}
|
||||
mike={!isError && !(showDone && doneVisible)}
|
||||
size={22}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import type { Citation } from "../../shared/types";
|
||||
|
||||
export function preprocessCitations(
|
||||
text: string,
|
||||
citations: Citation[],
|
||||
inlineCitationTargets: Citation[],
|
||||
): string {
|
||||
// Replace [N] or [N, M, ...] inline markers with internal §idx§ tokens backed by citations.
|
||||
return text.replace(/\[(\d+(?:,\s*\d+)*)\]/g, (full, refsStr) => {
|
||||
const refs = (refsStr as string)
|
||||
.split(",")
|
||||
.map((s: string) => parseInt(s.trim(), 10));
|
||||
const tokens = refs.flatMap((ref: number) => {
|
||||
const citation = citations.find((a) => a.ref === ref);
|
||||
if (!citation) return [];
|
||||
const idx = inlineCitationTargets.length;
|
||||
inlineCitationTargets.push(citation);
|
||||
return [`\`§${idx}§\`\u200B`];
|
||||
});
|
||||
return tokens.length > 0 ? tokens.join("") : full;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markdown renderer (shared config)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function internalCaseHref(
|
||||
value: string | number | null | undefined,
|
||||
): string | null {
|
||||
if (typeof value === "number") return `us-case-${value}`;
|
||||
if (!value) return null;
|
||||
const match = value.match(/^us-case-(\d+)$/);
|
||||
return match ? `us-case-${match[1]}` : null;
|
||||
}
|
||||
|
||||
33
frontend/src/app/components/assistant/message/eventUtils.ts
Normal file
33
frontend/src/app/components/assistant/message/eventUtils.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { AssistantEvent } from "../../shared/types";
|
||||
|
||||
export function eventErrorMessage(event: AssistantEvent): string | null {
|
||||
if (event.type === "error") return event.message;
|
||||
if ("error" in event && typeof event.error === "string" && event.error) {
|
||||
return event.error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function toolCallLabel(name: string): string {
|
||||
if (name === "ask_inputs") return "Asking for input...";
|
||||
if (name === "generate_docx") return "Creating document...";
|
||||
if (name === "generate_excel") return "Creating spreadsheet...";
|
||||
if (name === "generate_ppt") return "Creating presentation...";
|
||||
if (name === "edit_document") return "Editing document...";
|
||||
if (name === "read_document") return "Reading document...";
|
||||
if (name === "fetch_documents") return "Reading documents...";
|
||||
if (name === "find_in_document") return "Searching document...";
|
||||
if (name === "replicate_document") return "Copying document...";
|
||||
if (name === "read_workflow") return "Loading workflow...";
|
||||
if (name === "list_workflows") return "Loading workflows...";
|
||||
if (name === "list_documents") return "Loading documents...";
|
||||
if (name === "courtlistener_search_case_law")
|
||||
return "Searching case law...";
|
||||
if (name === "courtlistener_get_cases") return "Fetching cases...";
|
||||
if (name === "courtlistener_find_in_case") return "Searching case...";
|
||||
if (name === "courtlistener_read_case") return "Reading case...";
|
||||
if (name === "courtlistener_verify_citations")
|
||||
return "Verifying citations...";
|
||||
if (name.startsWith("mcp_")) return "Using connector...";
|
||||
return name ? `Running ${name}...` : "Working...";
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
export const RESPONSE_GLASS_SURFACE =
|
||||
"rounded-xl border border-white/70 bg-white/55 shadow-[0_3px_9px_rgba(15,23,42,0.03),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-2xl";
|
||||
export const RESPONSE_GLASS_ANNOTATION =
|
||||
"inline-flex h-4 w-4 items-center justify-center rounded-full border border-gray-200/60 bg-gray-200/80 text-[12px] font-serif font-medium text-gray-800 shadow-[0_1px_2px_rgba(15,23,42,0.04),inset_0_1px_0_rgba(243,244,246,0.85),inset_0_-2px_4px_rgba(229,231,235,0.65)] backdrop-blur-xl transition-colors hover:bg-gray-200 hover:text-gray-950";
|
||||
|
||||
export function withoutMarkdownNode<P extends { node?: unknown }>(
|
||||
props: P,
|
||||
): Omit<P, "node"> {
|
||||
const { node, ...rest } = props;
|
||||
void node;
|
||||
return rest;
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Hide jitter from arrival of streamed text chunks by revealing characters at
|
||||
* a smooth, rate-paced clip rather than rendering every chunk verbatim.
|
||||
*
|
||||
* Returns a prefix of `text` whose length grows over time toward the full
|
||||
* length. When `active` is false (stream ended, message replayed from
|
||||
* history, etc.), snaps to the full text immediately.
|
||||
*
|
||||
* Rate adapts to backlog: small backlogs reveal at a 40 cps floor; large
|
||||
* backlogs catch up within ~0.4s, so the smoothing never lags noticeably
|
||||
* behind the server.
|
||||
*/
|
||||
export function useSmoothedReveal(text: string, active: boolean): string {
|
||||
const [revealedInt, setRevealedInt] = useState(text.length);
|
||||
const revealedFloat = useRef<number>(text.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
revealedFloat.current = text.length;
|
||||
return;
|
||||
}
|
||||
|
||||
// Defensive clamp in case the text was edited / replaced shorter.
|
||||
if (revealedFloat.current > text.length) {
|
||||
revealedFloat.current = text.length;
|
||||
}
|
||||
|
||||
let lastTick = performance.now();
|
||||
let raf = 0;
|
||||
let cancelled = false;
|
||||
|
||||
const step = (now: number) => {
|
||||
if (cancelled) return;
|
||||
const dt = Math.max(0, (now - lastTick) / 1000);
|
||||
lastTick = now;
|
||||
const target = text.length;
|
||||
const prev = revealedFloat.current;
|
||||
if (prev < target) {
|
||||
const backlog = target - prev;
|
||||
const cps = Math.max(40, backlog / 0.4);
|
||||
const next = Math.min(target, prev + cps * dt);
|
||||
revealedFloat.current = next;
|
||||
const nextInt = Math.floor(next);
|
||||
setRevealedInt((cur) => (cur === nextInt ? cur : nextInt));
|
||||
}
|
||||
raf = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(step);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [text.length, active]);
|
||||
|
||||
return text.slice(0, Math.min(revealedInt, text.length));
|
||||
}
|
||||
|
||||
221
frontend/src/app/components/modals/AddDocumentsModal.tsx
Normal file
221
frontend/src/app/components/modals/AddDocumentsModal.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AlertCircle, Upload, Loader2, X } from "lucide-react";
|
||||
import {
|
||||
uploadStandaloneDocument,
|
||||
uploadProjectDocument,
|
||||
addDocumentToProject,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import type { Document } from "../shared/types";
|
||||
import { FileDirectory } from "../shared/FileDirectory";
|
||||
import {
|
||||
useDirectoryData,
|
||||
invalidateDirectoryCache,
|
||||
} from "../shared/useDirectoryData";
|
||||
import { Modal } from "./Modal";
|
||||
import {
|
||||
SUPPORTED_DOCUMENT_ACCEPT,
|
||||
formatUnsupportedDocumentWarning,
|
||||
partitionSupportedDocumentFiles,
|
||||
} from "@/app/lib/documentUploadValidation";
|
||||
|
||||
export { invalidateDirectoryCache };
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (documents: Document[], projectId?: string) => void;
|
||||
breadcrumb: string[];
|
||||
allowMultiple?: boolean;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export function AddDocumentsModal({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
breadcrumb,
|
||||
allowMultiple = true,
|
||||
projectId,
|
||||
}: Props) {
|
||||
const { loading, standaloneDocuments, projects } = useDirectoryData(open);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadingFilenames, setUploadingFilenames] = useState<string[]>([]);
|
||||
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
|
||||
const [extraUploadedDocs, setExtraUploadedDocs] = useState<Document[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelectedIds(new Set());
|
||||
setExtraUploadedDocs([]);
|
||||
setUploadingFilenames([]);
|
||||
setUploadWarning(null);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const allStandalone = [
|
||||
...extraUploadedDocs.filter(
|
||||
(u) => !standaloneDocuments.some((d) => d.id === u.id),
|
||||
),
|
||||
...standaloneDocuments,
|
||||
];
|
||||
|
||||
const availableProjects = projects
|
||||
.filter((p) => p.id !== projectId)
|
||||
.map((p) => ({
|
||||
...p,
|
||||
documents: p.documents || [],
|
||||
}));
|
||||
|
||||
const allDocs = [
|
||||
...allStandalone,
|
||||
...availableProjects.flatMap((p) => p.documents || []),
|
||||
];
|
||||
|
||||
async function handleConfirm() {
|
||||
const selected = allDocs.filter((d) => selectedIds.has(d.id));
|
||||
|
||||
if (projectId) {
|
||||
const toAssign = selected.filter((d) => d.project_id !== projectId);
|
||||
const alreadyHere = selected.filter(
|
||||
(d) => d.project_id === projectId,
|
||||
);
|
||||
if (toAssign.length > 0) {
|
||||
setUploading(true);
|
||||
try {
|
||||
const assigned = await Promise.all(
|
||||
toAssign.map((d) =>
|
||||
addDocumentToProject(projectId, d.id),
|
||||
),
|
||||
);
|
||||
onSelect([...alreadyHere, ...assigned], projectId);
|
||||
} catch (err) {
|
||||
console.error("Failed to assign documents:", err);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
} else {
|
||||
onSelect(alreadyHere, projectId);
|
||||
}
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
const projectIds = new Set(
|
||||
selected.map((d) => d.project_id).filter(Boolean),
|
||||
);
|
||||
const singleProjectId =
|
||||
projectIds.size === 1 ? [...projectIds][0]! : undefined;
|
||||
onSelect(selected, singleProjectId);
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
const { supported, unsupported } =
|
||||
partitionSupportedDocumentFiles(files);
|
||||
setUploadWarning(formatUnsupportedDocumentWarning(unsupported));
|
||||
if (supported.length === 0) {
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
return;
|
||||
}
|
||||
setUploadingFilenames(supported.map((file) => file.name));
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploaded = await Promise.all(
|
||||
supported.map((f) =>
|
||||
projectId
|
||||
? uploadProjectDocument(projectId, f)
|
||||
: uploadStandaloneDocument(f),
|
||||
),
|
||||
);
|
||||
invalidateDirectoryCache();
|
||||
setExtraUploadedDocs((prev) => [...uploaded, ...prev]);
|
||||
uploaded.forEach((d) =>
|
||||
setSelectedIds((prev) => new Set([...prev, d.id])),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Upload failed:", err);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadingFilenames([]);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
breadcrumbs={breadcrumb}
|
||||
secondaryAction={{
|
||||
label: uploading ? "Uploading…" : "Upload",
|
||||
icon: uploading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
),
|
||||
onClick: () => fileInputRef.current?.click(),
|
||||
disabled: uploading,
|
||||
}}
|
||||
footerStatus={
|
||||
selectedIds.size > 0 ? (
|
||||
<span className="text-xs text-gray-400">
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
primaryAction={{
|
||||
label: uploading ? "Saving…" : "Confirm",
|
||||
onClick: handleConfirm,
|
||||
disabled: selectedIds.size === 0 || uploading,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={SUPPORTED_DOCUMENT_ACCEPT}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
|
||||
{uploadWarning && (
|
||||
<div className="mb-2 flex items-center gap-2 rounded-lg border border-red-100 bg-red-50 px-3 py-2 text-xs text-gray-900">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-red-600" />
|
||||
<span className="min-w-0 flex-1">{uploadWarning}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUploadWarning(null)}
|
||||
className="shrink-0 rounded p-0.5 text-black hover:bg-gray-100"
|
||||
aria-label="Dismiss warning"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<FileDirectory
|
||||
standaloneDocs={allStandalone}
|
||||
directoryProjects={availableProjects}
|
||||
loading={loading}
|
||||
selectedIds={selectedIds}
|
||||
onChange={setSelectedIds}
|
||||
allowMultiple={allowMultiple}
|
||||
emptyMessage="No documents yet"
|
||||
uploadingFilenames={uploadingFilenames}
|
||||
searchable
|
||||
searchAutoFocus
|
||||
searchNoResultsMessage="No matches found"
|
||||
showProjectTabs
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
250
frontend/src/app/components/modals/AddProjectDocsModal.tsx
Normal file
250
frontend/src/app/components/modals/AddProjectDocsModal.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, Loader2, Upload, X } from "lucide-react";
|
||||
import { SearchBar } from "@/app/components/ui/search-bar";
|
||||
import { getProject, uploadProjectDocument } from "@/app/lib/mikeApi";
|
||||
import type { Document } from "../shared/types";
|
||||
import { DocFileIcon } from "../shared/FileDirectory";
|
||||
import { VersionChip } from "../shared/VersionChip";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (documents: Document[]) => void;
|
||||
breadcrumb: string[];
|
||||
projectId: string;
|
||||
/** Docs already in the target list — rendered checked + disabled. */
|
||||
excludeDocIds?: Set<string>;
|
||||
allowMultiple?: boolean;
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
if (!iso) return null;
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function AddProjectDocsModal({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
breadcrumb,
|
||||
projectId,
|
||||
excludeDocIds,
|
||||
allowMultiple = true,
|
||||
}: Props) {
|
||||
const [docs, setDocs] = useState<Document[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSearch("");
|
||||
setSelectedIds(new Set());
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
getProject(projectId)
|
||||
.then((p) => {
|
||||
if (!cancelled) setDocs(p.documents ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDocs([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, projectId]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const q = search.toLowerCase().trim();
|
||||
const filtered = q
|
||||
? docs.filter((d) => d.filename.toLowerCase().includes(q))
|
||||
: docs;
|
||||
|
||||
const isExcluded = (id: string) => !!excludeDocIds?.has(id);
|
||||
|
||||
function toggle(id: string) {
|
||||
if (isExcluded(id)) return;
|
||||
if (!allowMultiple) {
|
||||
setSelectedIds(new Set([id]));
|
||||
return;
|
||||
}
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
const selected = docs.filter((d) => selectedIds.has(d.id));
|
||||
onSelect(selected);
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploaded = await Promise.all(
|
||||
files.map((f) => uploadProjectDocument(projectId, f)),
|
||||
);
|
||||
setDocs((prev) => [...uploaded, ...prev]);
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
uploaded.forEach((d) => next.add(d.id));
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Upload failed:", err);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
breadcrumbs={breadcrumb}
|
||||
secondaryAction={{
|
||||
label: uploading ? "Uploading…" : "Upload",
|
||||
icon: uploading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
),
|
||||
onClick: () => fileInputRef.current?.click(),
|
||||
disabled: uploading,
|
||||
}}
|
||||
footerStatus={
|
||||
selectedIds.size > 0 ? (
|
||||
<span className="text-xs text-gray-400">
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
primaryAction={{
|
||||
label: "Confirm",
|
||||
onClick: handleConfirm,
|
||||
disabled: selectedIds.size === 0 || uploading,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.docx,.doc,.xlsx,.xlsm,.xls,.pptx,.ppt"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<div className="pt-1 pb-2">
|
||||
<SearchBar
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder="Search..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="rounded-sm border border-gray-100 overflow-hidden">
|
||||
{[60, 45, 75, 55, 40].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 px-2 py-2"
|
||||
>
|
||||
<div className="h-3.5 w-3.5 rounded border border-gray-200 shrink-0" />
|
||||
<div className="h-3.5 w-3.5 rounded bg-gray-100 animate-pulse shrink-0" />
|
||||
<div
|
||||
className="h-3 rounded bg-gray-100 animate-pulse"
|
||||
style={{ width: `${w}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-center text-sm text-gray-400 py-8">
|
||||
{q ? "No matches found" : "No documents in this project"}
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-sm border border-gray-100 overflow-hidden">
|
||||
{filtered.map((doc) => {
|
||||
const excluded = isExcluded(doc.id);
|
||||
const checked = excluded || selectedIds.has(doc.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={doc.id}
|
||||
disabled={excluded}
|
||||
onClick={() => toggle(doc.id)}
|
||||
className={`w-full flex items-center gap-2 rounded-md px-2 py-2 text-xs text-left transition-all ${
|
||||
excluded
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: checked
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-100/70"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
|
||||
checked
|
||||
? "bg-gray-900 border-gray-900"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{checked && (
|
||||
<Check className="h-2.5 w-2.5 text-white" />
|
||||
)}
|
||||
</span>
|
||||
<DocFileIcon fileType={doc.file_type} />
|
||||
<span
|
||||
className={`flex-1 truncate ${
|
||||
checked
|
||||
? "text-gray-900"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{doc.filename}
|
||||
</span>
|
||||
{excluded && (
|
||||
<span className="text-[10px] text-gray-400 shrink-0">
|
||||
Already added
|
||||
</span>
|
||||
)}
|
||||
<VersionChip
|
||||
n={
|
||||
doc.active_version_number ??
|
||||
doc.latest_version_number
|
||||
}
|
||||
/>
|
||||
{doc.created_at && (
|
||||
<span className="shrink-0 text-gray-300">
|
||||
{formatDate(doc.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
203
frontend/src/app/components/modals/Modal.tsx
Normal file
203
frontend/src/app/components/modals/Modal.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
"use client";
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { PillButton } from "@/app/components/ui/pill-button";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
|
||||
type ModalSize = "sm" | "md" | "lg" | "xl";
|
||||
type ModalAction = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"className"
|
||||
> & {
|
||||
label: ReactNode;
|
||||
icon?: ReactNode;
|
||||
variant?: "primary" | "secondary" | "danger";
|
||||
};
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
breadcrumbs?: ReactNode[];
|
||||
headerAction?: ReactNode;
|
||||
size?: ModalSize;
|
||||
className?: string;
|
||||
footerStatus?: ReactNode;
|
||||
primaryAction?: ModalAction;
|
||||
secondaryAction?: ModalAction;
|
||||
cancelAction?: ModalAction | false;
|
||||
}
|
||||
|
||||
const sizeClassName: Record<ModalSize, string> = {
|
||||
sm: "max-w-md",
|
||||
md: "max-w-xl",
|
||||
lg: "max-w-2xl",
|
||||
xl: "max-w-4xl",
|
||||
};
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
breadcrumbs,
|
||||
headerAction,
|
||||
size = "lg",
|
||||
className,
|
||||
footerStatus,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
cancelAction,
|
||||
}: ModalProps) {
|
||||
const hasHeader = breadcrumbs?.length;
|
||||
const hasFooter =
|
||||
footerStatus ||
|
||||
primaryAction ||
|
||||
secondaryAction ||
|
||||
cancelAction;
|
||||
const resolvedCancelAction = cancelAction;
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-[200] flex items-center justify-center px-4",
|
||||
"bg-white/10 backdrop-blur-[2px]",
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full rounded-3xl flex h-[600px] flex-col",
|
||||
sizeClassName[size],
|
||||
"border border-white/70 bg-gray-50/95 shadow-[0_14px_40px_rgba(15,23,42,0.101),0_5px_14px_rgba(15,23,42,0.067)] backdrop-blur-3xl",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{hasHeader && (
|
||||
<div className="flex items-center justify-between gap-3 p-4 pl-5">
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5 text-xs leading-none text-gray-400">
|
||||
{breadcrumbs?.map((segment, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{index > 0 && <span>›</span>}
|
||||
<span
|
||||
className={cn(
|
||||
"truncate",
|
||||
index ===
|
||||
(breadcrumbs?.length ?? 0) -
|
||||
1 && "text-gray-700",
|
||||
)}
|
||||
>
|
||||
{segment}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{headerAction}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-white/70 bg-white/55 text-gray-500 shadow-[inset_0_1px_0_rgba(255,255,255,0.75),inset_0_-1px_0_rgba(255,255,255,0.55),0_6px_18px_rgba(15,23,42,0.08)] backdrop-blur-xl transition-colors hover:bg-white/75 hover:text-gray-700"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Body never scrolls itself (so children's edge shadows are
|
||||
never clipped by the header/footer). Content that can exceed
|
||||
the modal height wraps its scrollable region in an inner
|
||||
`overflow-y-auto` div. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col px-5">
|
||||
{children}
|
||||
</div>
|
||||
{hasFooter && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3",
|
||||
secondaryAction
|
||||
? "justify-between"
|
||||
: "justify-end",
|
||||
"border-t border-white/60",
|
||||
)}
|
||||
>
|
||||
{secondaryAction && (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ModalActionButton
|
||||
action={secondaryAction}
|
||||
fallbackVariant="secondary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{footerStatus}
|
||||
{resolvedCancelAction && (
|
||||
<ModalActionButton
|
||||
action={resolvedCancelAction}
|
||||
fallbackVariant="cancel"
|
||||
/>
|
||||
)}
|
||||
{primaryAction && (
|
||||
<ModalActionButton
|
||||
action={primaryAction}
|
||||
fallbackVariant="primary"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function ModalActionButton({
|
||||
action,
|
||||
fallbackVariant,
|
||||
}: {
|
||||
action: ModalAction;
|
||||
fallbackVariant: "primary" | "secondary" | "danger" | "cancel";
|
||||
}) {
|
||||
const {
|
||||
label,
|
||||
icon,
|
||||
variant = fallbackVariant === "cancel" ? "secondary" : fallbackVariant,
|
||||
...props
|
||||
} = action;
|
||||
|
||||
if (fallbackVariant === "cancel") {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="px-2 py-1.5 text-sm text-gray-500 transition-colors hover:text-gray-800 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
{...props}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const tone =
|
||||
variant === "danger"
|
||||
? "danger"
|
||||
: fallbackVariant === "secondary" && variant === "secondary"
|
||||
? "blue"
|
||||
: variant === "primary"
|
||||
? "black"
|
||||
: "white";
|
||||
|
||||
return (
|
||||
<PillButton tone={tone} size="normal" {...props}>
|
||||
{icon}
|
||||
{label}
|
||||
</PillButton>
|
||||
);
|
||||
}
|
||||
35
frontend/src/app/components/modals/ModalFieldLabel.tsx
Normal file
35
frontend/src/app/components/modals/ModalFieldLabel.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"use client";
|
||||
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
|
||||
type ModalFieldLabelProps = ComponentPropsWithoutRef<"label"> & {
|
||||
children: ReactNode;
|
||||
as?: "label" | "p" | "span";
|
||||
};
|
||||
|
||||
export function ModalFieldLabel({
|
||||
as = "label",
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ModalFieldLabelProps) {
|
||||
const classes = cn(
|
||||
"mb-2 block text-xs font-medium text-gray-700",
|
||||
className,
|
||||
);
|
||||
|
||||
if (as === "p") {
|
||||
return <p className={classes}>{children}</p>;
|
||||
}
|
||||
|
||||
if (as === "span") {
|
||||
return <span className={classes}>{children}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<label className={classes} {...props}>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
71
frontend/src/app/components/modals/ModalSegmentedToggle.tsx
Normal file
71
frontend/src/app/components/modals/ModalSegmentedToggle.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
|
||||
export interface SegmentedToggleOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
interface ModalSegmentedToggleProps<T extends string> {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
options: SegmentedToggleOption<T>[];
|
||||
disabled?: boolean;
|
||||
size?: "sm" | "md";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ModalSegmentedToggle<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
disabled = false,
|
||||
size = "md",
|
||||
className,
|
||||
}: ModalSegmentedToggleProps<T>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-grid gap-1 rounded-full bg-gray-100",
|
||||
size === "sm" ? "h-8 p-1" : "h-9 p-1",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const active = option.value === value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
disabled={disabled}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"flex h-full items-center justify-center rounded-full text-xs transition-all disabled:cursor-not-allowed disabled:opacity-60",
|
||||
size === "sm" ? "gap-1 px-3" : "gap-1.5 px-3",
|
||||
active
|
||||
? "bg-white/80 text-gray-900 shadow-[0_5px_16px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.92),inset_0_-1px_0_rgba(255,255,255,0.62)] backdrop-blur-xl"
|
||||
: "text-gray-500 hover:text-gray-700",
|
||||
)}
|
||||
>
|
||||
{Icon && (
|
||||
<Icon
|
||||
className={
|
||||
size === "sm" ? "h-2.5 w-2.5" : "h-3 w-3"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
frontend/src/app/components/modals/ModalSelect.tsx
Normal file
146
frontend/src/app/components/modals/ModalSelect.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, type LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
|
||||
export type ModalSelectOption =
|
||||
| string
|
||||
| {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
iconClassName?: string;
|
||||
};
|
||||
|
||||
interface ModalSelectProps {
|
||||
id: string;
|
||||
value: string;
|
||||
options: readonly ModalSelectOption[];
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
className?: string;
|
||||
menuClassName?: string;
|
||||
}
|
||||
|
||||
function normalizeOption(option: ModalSelectOption) {
|
||||
return typeof option === "string"
|
||||
? { value: option, label: option }
|
||||
: option;
|
||||
}
|
||||
|
||||
export function ModalSelect({
|
||||
id,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
placeholder = "Select...",
|
||||
disabled = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
className,
|
||||
menuClassName,
|
||||
}: ModalSelectProps) {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const isOpen = open ?? internalOpen;
|
||||
const normalizedOptions = options.map(normalizeOption);
|
||||
const selected = normalizedOptions.find((option) => option.value === value);
|
||||
const hasValue = value.trim().length > 0;
|
||||
|
||||
function setOpen(next: boolean) {
|
||||
onOpenChange?.(next);
|
||||
if (open === undefined) {
|
||||
setInternalOpen(next);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(nextValue: string) {
|
||||
setOpen(false);
|
||||
onChange(nextValue);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
onClick={() => setOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-xl border border-white/70 bg-white/55 px-3 text-sm text-gray-700 shadow-[0_3px_9px_rgba(15,23,42,0.052),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-xl transition-colors hover:bg-white/70 focus:bg-white/70 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60",
|
||||
className,
|
||||
)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{selected?.icon && (
|
||||
<selected.icon
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
selected.iconClassName,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"truncate",
|
||||
!selected && !hasValue && "text-gray-400",
|
||||
)}
|
||||
>
|
||||
{selected?.label ?? (hasValue ? value : placeholder)}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"ml-2 h-3.5 w-3.5 shrink-0 text-gray-400 transition-transform",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{isOpen && !disabled && (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-labelledby={id}
|
||||
className={cn(
|
||||
"absolute left-0 top-full z-30 mt-1 max-h-56 w-full overflow-y-auto rounded-2xl border border-white/70 bg-gray-50/95 p-1 shadow-[0_12px_32px_rgba(15,23,42,0.156),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-2xl",
|
||||
menuClassName,
|
||||
)}
|
||||
>
|
||||
{normalizedOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={option.value === value}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
className={cn(
|
||||
"flex w-full items-center rounded-md px-3 py-2 text-left text-sm transition-all hover:bg-gray-100/70",
|
||||
option.value === value
|
||||
? "bg-gray-100 text-gray-900"
|
||||
: "text-gray-700",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{option.icon && (
|
||||
<option.icon
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
option.iconClassName,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{option.label}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
frontend/src/app/components/modals/ModalTextInput.tsx
Normal file
28
frontend/src/app/components/modals/ModalTextInput.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { forwardRef, type InputHTMLAttributes } from "react";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
|
||||
type ModalTextInputVariant = "glass" | "minimal";
|
||||
|
||||
type ModalTextInputProps = InputHTMLAttributes<HTMLInputElement> & {
|
||||
variant?: ModalTextInputVariant;
|
||||
};
|
||||
|
||||
const variantClasses: Record<ModalTextInputVariant, string> = {
|
||||
glass: "h-10 w-full rounded-xl border border-white/70 bg-white px-3 text-sm text-gray-700 shadow-[0_3px_9px_rgba(15,23,42,0.052),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] outline-none placeholder:text-gray-400 backdrop-blur-xl transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||
minimal:
|
||||
"w-full bg-transparent font-serif text-2xl text-gray-800 outline-none placeholder:text-gray-300 disabled:cursor-not-allowed disabled:text-gray-400",
|
||||
};
|
||||
|
||||
export const ModalTextInput = forwardRef<HTMLInputElement, ModalTextInputProps>(
|
||||
({ className, variant = "glass", ...props }, ref) => (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(variantClasses[variant], className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
ModalTextInput.displayName = "ModalTextInput";
|
||||
22
frontend/src/app/components/modals/ModalTextarea.tsx
Normal file
22
frontend/src/app/components/modals/ModalTextarea.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import { forwardRef, type TextareaHTMLAttributes } from "react";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
|
||||
type ModalTextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
|
||||
export const ModalTextarea = forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
ModalTextareaProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"min-h-24 w-full resize-none rounded-xl border border-white/70 bg-white px-3 py-2.5 text-sm leading-relaxed text-gray-700 shadow-[0_3px_9px_rgba(15,23,42,0.052),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] outline-none placeholder:text-gray-400 backdrop-blur-xl transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
ModalTextarea.displayName = "ModalTextarea";
|
||||
435
frontend/src/app/components/modals/PeopleModal.tsx
Normal file
435
frontend/src/app/components/modals/PeopleModal.tsx
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { User, Loader2 } from "lucide-react";
|
||||
import type { ProjectPeople } from "@/app/lib/mikeApi";
|
||||
import { AddUserInput } from "../shared/AddUserInput";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
/**
|
||||
* Any resource the modal can manage members for — projects today, tabular
|
||||
* reviews now, anything else with a `shared_with` email list later.
|
||||
*/
|
||||
export interface SharedResource {
|
||||
id: string;
|
||||
shared_with?: string[] | null;
|
||||
owner_display_name?: string | null;
|
||||
owner_email?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** The thing being shared (project, review, …). */
|
||||
resource: SharedResource | null;
|
||||
/**
|
||||
* Resolve the owner + members roster for the given resource. Different
|
||||
* resource types hit different endpoints (`/projects/:id/people`,
|
||||
* `/tabular-review/:id/people`, …) so the caller passes the appropriate
|
||||
* fetcher.
|
||||
*/
|
||||
fetchPeople: (id: string) => Promise<ProjectPeople>;
|
||||
/** Currently signed-in user's email — gets the "You" tag if it matches. */
|
||||
currentUserEmail?: string | null;
|
||||
breadcrumb: string[];
|
||||
/**
|
||||
* Persist a new shared_with list. Parent should PATCH the resource and
|
||||
* sync its local state on success. Throw to surface an error inline.
|
||||
*/
|
||||
onSharedWithChange?: (sharedWith: string[]) => Promise<void> | void;
|
||||
}
|
||||
|
||||
type RosterRow = {
|
||||
email: string | null;
|
||||
user_id?: string | null;
|
||||
display_name: string | null;
|
||||
role: "owner" | "member";
|
||||
};
|
||||
|
||||
/**
|
||||
* Roster of every Mike member with access to the project, with controls to
|
||||
* add/remove members. Mirrors AddDocumentsModal's frame.
|
||||
*/
|
||||
export function PeopleModal({
|
||||
open,
|
||||
onClose,
|
||||
resource,
|
||||
fetchPeople,
|
||||
currentUserEmail,
|
||||
breadcrumb,
|
||||
onSharedWithChange,
|
||||
}: Props) {
|
||||
const [busy, setBusy] = useState<"add" | "remove" | null>(null);
|
||||
const [removingEmail, setRemovingEmail] = useState<string | null>(null);
|
||||
const [memberMenuEmail, setMemberMenuEmail] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Server-resolved roster: owner email/display_name + members'
|
||||
// display_names. We keep `resource.shared_with` as the source of truth
|
||||
// for membership and just merge display_names from this fetch.
|
||||
const [people, setPeople] = useState<ProjectPeople | null>(null);
|
||||
const [lookupDisplayByEmail, setLookupDisplayByEmail] = useState<
|
||||
Map<string, string | null>
|
||||
>(new Map());
|
||||
const [peopleLoading, setPeopleLoading] = useState(false);
|
||||
const [loadedRosterKey, setLoadedRosterKey] = useState<string | null>(null);
|
||||
|
||||
const resourceId = resource?.id ?? null;
|
||||
const sharedWith: string[] = useMemo(
|
||||
() =>
|
||||
Array.isArray(resource?.shared_with)
|
||||
? (resource.shared_with as string[])
|
||||
: [],
|
||||
[resource?.shared_with],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setError(null);
|
||||
setBusy(null);
|
||||
setRemovingEmail(null);
|
||||
setMemberMenuEmail(null);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!memberMenuEmail) return;
|
||||
function handleClickAway(event: PointerEvent) {
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof HTMLElement &&
|
||||
target.closest("[data-people-member-menu]")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setMemberMenuEmail(null);
|
||||
}
|
||||
document.addEventListener("pointerdown", handleClickAway);
|
||||
return () =>
|
||||
document.removeEventListener("pointerdown", handleClickAway);
|
||||
}, [memberMenuEmail]);
|
||||
|
||||
// Re-fetch roster whenever the modal opens or membership changes —
|
||||
// keyed by the joined shared_with list so add/remove triggers a refresh.
|
||||
const sharedKey = sharedWith
|
||||
.map((e) => e.toLowerCase())
|
||||
.sort()
|
||||
.join(",");
|
||||
const rosterKey = `${resourceId ?? ""}:${sharedKey}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !resourceId) return;
|
||||
let cancelled = false;
|
||||
setPeopleLoading(true);
|
||||
setPeople(null);
|
||||
setLoadedRosterKey(null);
|
||||
fetchPeople(resourceId)
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setPeople(data);
|
||||
setLoadedRosterKey(rosterKey);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoadedRosterKey(rosterKey);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPeopleLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, resourceId, rosterKey, fetchPeople]);
|
||||
|
||||
if (!open || !resource) return null;
|
||||
|
||||
const memberDisplayByEmail = new Map<string, string | null>();
|
||||
for (const m of people?.members ?? []) {
|
||||
memberDisplayByEmail.set(m.email.toLowerCase(), m.display_name);
|
||||
}
|
||||
const ownerEmail =
|
||||
people?.owner.email?.trim().toLowerCase() ??
|
||||
resource.owner_email?.trim().toLowerCase() ??
|
||||
null;
|
||||
const ownerDisplayName =
|
||||
people?.owner.display_name ?? resource.owner_display_name ?? null;
|
||||
|
||||
const roster: RosterRow[] = [];
|
||||
if (people?.owner || ownerEmail || ownerDisplayName) {
|
||||
roster.push({
|
||||
email: ownerEmail,
|
||||
user_id: people?.owner.user_id ?? null,
|
||||
display_name: ownerDisplayName,
|
||||
role: "owner",
|
||||
});
|
||||
}
|
||||
for (const email of sharedWith) {
|
||||
const lower = email.toLowerCase();
|
||||
if (ownerEmail && lower === ownerEmail.toLowerCase()) continue;
|
||||
roster.push({
|
||||
email,
|
||||
display_name:
|
||||
memberDisplayByEmail.get(lower) ??
|
||||
lookupDisplayByEmail.get(lower) ??
|
||||
null,
|
||||
role: "member",
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedCurrentUserEmail =
|
||||
currentUserEmail?.trim().toLowerCase() ?? null;
|
||||
const sharedLower = sharedWith.map((e) => e.toLowerCase());
|
||||
const rosterPending = peopleLoading || loadedRosterKey !== rosterKey;
|
||||
|
||||
function validateNewEmail(email: string) {
|
||||
if (sharedLower.includes(email)) return `${email} already has access.`;
|
||||
if (ownerEmail && email === ownerEmail.toLowerCase()) {
|
||||
return `${email} is the owner.`;
|
||||
}
|
||||
if (
|
||||
normalizedCurrentUserEmail &&
|
||||
email === normalizedCurrentUserEmail
|
||||
) {
|
||||
return "You cannot share this with yourself.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleAddUser(user: {
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
}) {
|
||||
setLookupDisplayByEmail((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(user.email.trim().toLowerCase(), user.display_name);
|
||||
return next;
|
||||
});
|
||||
await handleAdd(user.email);
|
||||
}
|
||||
|
||||
async function handleAdd(email: string) {
|
||||
if (!onSharedWithChange || busy !== null) return;
|
||||
setBusy("add");
|
||||
setError(null);
|
||||
try {
|
||||
const next = [...sharedWith, email];
|
||||
await onSharedWithChange(next);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: "Couldn't add the member. Try again.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(email: string) {
|
||||
if (!onSharedWithChange || busy !== null) return;
|
||||
setBusy("remove");
|
||||
setRemovingEmail(email);
|
||||
setError(null);
|
||||
try {
|
||||
const next = sharedWith.filter(
|
||||
(e) => e.toLowerCase() !== email.toLowerCase(),
|
||||
);
|
||||
await onSharedWithChange(next);
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: "Couldn't remove the member. Try again.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
setRemovingEmail(null);
|
||||
setMemberMenuEmail(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} breadcrumbs={breadcrumb}>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-5 pb-5">
|
||||
{/* Add-member row */}
|
||||
{onSharedWithChange && (
|
||||
<section className="space-y-2">
|
||||
<AddUserInput
|
||||
onAdd={handleAddUser}
|
||||
validateEmail={validateNewEmail}
|
||||
busy={busy === "add"}
|
||||
placeholder="Add by email..."
|
||||
autoFocus
|
||||
submitLabel="Add member"
|
||||
className="bg-white focus-within:bg-white"
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1.5 text-xs text-red-500">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<h3 className="text-xs font-medium text-gray-500">
|
||||
People with Access
|
||||
</h3>
|
||||
{peopleLoading && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Member list */}
|
||||
{rosterPending ? (
|
||||
<div className="min-h-0 flex-1 space-y-1">
|
||||
{[1, 2].map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="flex items-center gap-2.5 rounded-lg px-2 py-1.5"
|
||||
>
|
||||
<div className="h-6 w-6 shrink-0 animate-pulse rounded-full bg-gray-100" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="h-3 w-40 animate-pulse rounded bg-gray-100" />
|
||||
</div>
|
||||
<div className="h-4 w-12 shrink-0 animate-pulse rounded-full bg-gray-100" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : roster.length === 0 ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-gray-400">
|
||||
No one has access yet.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="min-h-0 flex-1 space-y-1 overflow-y-auto">
|
||||
{roster.map((entry) => {
|
||||
const entryEmail = entry.email ?? "";
|
||||
const rowKey =
|
||||
entry.email ??
|
||||
entry.user_id ??
|
||||
`${entry.role}-unknown`;
|
||||
const isYou =
|
||||
!!currentUserEmail &&
|
||||
!!entryEmail &&
|
||||
entryEmail.toLowerCase() ===
|
||||
currentUserEmail.toLowerCase();
|
||||
const isRemoving =
|
||||
busy === "remove" &&
|
||||
removingEmail === entryEmail;
|
||||
const displayName = entry.display_name?.trim();
|
||||
const primary = isYou
|
||||
? "You"
|
||||
: displayName || entryEmail || "User";
|
||||
const showEmail =
|
||||
!isYou && !!displayName && !!entryEmail;
|
||||
const initial = displayName
|
||||
?.charAt(0)
|
||||
.toUpperCase();
|
||||
return (
|
||||
<li
|
||||
key={`${entry.role}-${rowKey}`}
|
||||
className="group relative flex items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors hover:bg-gray-100/70"
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-white/80 bg-white text-gray-700 shadow-[0_4px_12px_rgba(15,23,42,0.10),inset_0_1px_0_rgba(255,255,255,0.92),inset_0_-1px_0_rgba(255,255,255,0.64)]">
|
||||
{initial ? (
|
||||
<span className="font-serif text-[11px] leading-none">
|
||||
{initial}
|
||||
</span>
|
||||
) : (
|
||||
<User className="h-2.5 w-2.5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs text-gray-800">
|
||||
{primary}
|
||||
{showEmail && (
|
||||
<span className="text-gray-400">
|
||||
{" "}
|
||||
· {entry.email}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{entry.role === "owner" && (
|
||||
<span className="shrink-0 rounded-full px-2 py-1 text-xs text-gray-400">
|
||||
Owner
|
||||
</span>
|
||||
)}
|
||||
{entry.role === "member" && (
|
||||
<div
|
||||
className="relative flex shrink-0 items-center"
|
||||
data-people-member-menu
|
||||
>
|
||||
<span className="rounded-full px-2 py-1 text-xs text-gray-400">
|
||||
Member
|
||||
</span>
|
||||
{onSharedWithChange && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setMemberMenuEmail(
|
||||
(current) =>
|
||||
current ===
|
||||
entryEmail
|
||||
? null
|
||||
: entryEmail,
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
busy !== null
|
||||
}
|
||||
title="Member actions"
|
||||
className={`flex h-6 items-center justify-center overflow-hidden rounded-full text-xs leading-none text-gray-500 transition-all hover:bg-gray-200/70 hover:text-gray-800 disabled:opacity-50 ${
|
||||
memberMenuEmail ===
|
||||
entryEmail
|
||||
? "w-6 opacity-100"
|
||||
: "w-0 opacity-0 group-hover:w-6 group-hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
···
|
||||
</button>
|
||||
{memberMenuEmail ===
|
||||
entryEmail && (
|
||||
<div
|
||||
className="absolute right-0 top-full z-30 mt-1 min-w-28 overflow-hidden rounded-xl border border-white/70 bg-gray-50/95 p-1 shadow-[0_8px_20px_rgba(15,23,42,0.09),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-2xl"
|
||||
onClick={(
|
||||
event,
|
||||
) =>
|
||||
event.stopPropagation()
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void handleRemove(
|
||||
entryEmail,
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
busy !==
|
||||
null
|
||||
}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs text-red-500 transition-colors hover:bg-red-500/10 disabled:opacity-50"
|
||||
>
|
||||
{isRemoving && (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
)}
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
136
frontend/src/app/components/modals/ProjectPickerModal.tsx
Normal file
136
frontend/src/app/components/modals/ProjectPickerModal.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"use client";
|
||||
|
||||
import { useState, type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
import { Folder } from "lucide-react";
|
||||
import { SearchBar } from "@/app/components/ui/search-bar";
|
||||
import type { Project } from "../shared/types";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
type PrimaryAction = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"className"
|
||||
> & {
|
||||
label: ReactNode;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
projects: Project[];
|
||||
loading: boolean;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
breadcrumbs?: ReactNode[];
|
||||
primaryAction?: PrimaryAction;
|
||||
}
|
||||
|
||||
export function ProjectPickerModal({
|
||||
open,
|
||||
onClose,
|
||||
projects,
|
||||
loading,
|
||||
selectedId,
|
||||
onSelect,
|
||||
breadcrumbs,
|
||||
primaryAction,
|
||||
}: Props) {
|
||||
const [search, setSearch] = useState("");
|
||||
const q = search.toLowerCase().trim();
|
||||
const filtered = q
|
||||
? projects.filter((p) => p.name.toLowerCase().includes(q))
|
||||
: projects;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
breadcrumbs={breadcrumbs}
|
||||
primaryAction={primaryAction}
|
||||
>
|
||||
<div className="pt-1 pb-2">
|
||||
<SearchBar
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder="Search projects..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pb-2">
|
||||
{loading ? (
|
||||
<div className="space-y-px">
|
||||
<div className="flex items-center rounded-md px-2 py-2">
|
||||
<div className="h-3 w-14 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
{[65, 45, 80, 55, 70].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-2"
|
||||
>
|
||||
<div className="h-3.5 w-3.5 rounded-full border border-gray-200 shrink-0" />
|
||||
<div className="h-3.5 w-3.5 rounded bg-gray-100 animate-pulse shrink-0" />
|
||||
<div
|
||||
className="h-3 rounded bg-gray-100 animate-pulse"
|
||||
style={{ width: `${w}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-center text-sm text-gray-400 py-8">
|
||||
{q ? "No matches found" : "No projects yet"}
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-sm overflow-hidden">
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<p className="text-xs font-medium text-gray-400">
|
||||
Projects
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-px">
|
||||
{filtered.map((project) => {
|
||||
const isSelected = selectedId === project.id;
|
||||
const documentCount =
|
||||
project.document_count ??
|
||||
project.documents?.length ??
|
||||
0;
|
||||
return (
|
||||
<button
|
||||
key={project.id}
|
||||
onClick={() =>
|
||||
onSelect(
|
||||
isSelected ? null : project.id,
|
||||
)
|
||||
}
|
||||
className={`w-full flex rounded-md items-center gap-2 px-2 py-2 text-xs transition-all text-left ${isSelected ? "bg-gray-100" : "hover:bg-gray-100/70"}`}
|
||||
>
|
||||
<span
|
||||
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${isSelected ? "bg-gray-900 border-gray-900" : "border-gray-300"}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<span className="h-1.5 w-1.5 rounded-sm bg-white" />
|
||||
)}
|
||||
</span>
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
<span
|
||||
className={`flex-1 truncate ${isSelected ? "text-gray-900" : "text-gray-700"}`}
|
||||
>
|
||||
{project.name}
|
||||
{project.cm_number && (
|
||||
<span className="ml-1 font-normal text-gray-400">
|
||||
(#{project.cm_number})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 text-gray-400">
|
||||
{documentCount}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
45
frontend/src/app/components/popups/ApiKeyMissingPopup.tsx
Normal file
45
frontend/src/app/components/popups/ApiKeyMissingPopup.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { providerLabel, type ModelProvider } from "@/app/lib/modelAvailability";
|
||||
import { WarningPopup } from "../popups/WarningPopup";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
provider: ModelProvider | null;
|
||||
/** Optional override for the body sentence. */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function ApiKeyMissingPopup({ open, onClose, provider, message }: Props) {
|
||||
const router = useRouter();
|
||||
if (!open) return null;
|
||||
|
||||
const providerName = provider ? providerLabel(provider) : "this provider";
|
||||
const body =
|
||||
message ??
|
||||
`You haven't added a ${providerName} API key yet. Add one in your account settings to use this model.`;
|
||||
|
||||
const handleGoToAccount = () => {
|
||||
onClose();
|
||||
router.push("/account/models");
|
||||
};
|
||||
|
||||
return (
|
||||
<WarningPopup
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="API key required"
|
||||
message={body}
|
||||
icon={
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-600" />
|
||||
}
|
||||
primaryAction={{
|
||||
label: "Go to account settings",
|
||||
onClick: handleGoToAccount,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
114
frontend/src/app/components/popups/ConfirmPopup.tsx
Normal file
114
frontend/src/app/components/popups/ConfirmPopup.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"use client";
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import { PillButton } from "@/app/components/ui/pill-button";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
|
||||
type ConfirmStatus = "idle" | "loading" | "complete";
|
||||
|
||||
interface ConfirmPopupProps {
|
||||
open: boolean;
|
||||
title?: ReactNode;
|
||||
message?: ReactNode;
|
||||
confirmLabel?: ReactNode;
|
||||
confirmStatus?: ConfirmStatus;
|
||||
cancelLabel?: ReactNode;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
confirmDisabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ConfirmPopup({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Confirm",
|
||||
confirmStatus = "idle",
|
||||
cancelLabel = "Cancel",
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmDisabled = false,
|
||||
className,
|
||||
}: ConfirmPopupProps) {
|
||||
if (!open) return null;
|
||||
const confirmBusy = confirmStatus === "loading";
|
||||
const resolvedConfirmDisabled = confirmDisabled || confirmStatus !== "idle";
|
||||
const normalizedConfirmLabel =
|
||||
typeof confirmLabel === "string" ? confirmLabel : "Confirm";
|
||||
const isDeleteAction = normalizedConfirmLabel.toLowerCase() === "delete";
|
||||
const resolvedConfirmLabel =
|
||||
confirmStatus === "loading" ? (
|
||||
<span className="inline-flex h-full items-center gap-1.5">
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
|
||||
{progressiveLabel(normalizedConfirmLabel)}
|
||||
</span>
|
||||
) : confirmStatus === "complete" ? (
|
||||
completedLabel(normalizedConfirmLabel)
|
||||
) : isDeleteAction ? (
|
||||
<span className="inline-flex h-full items-center gap-1.5">
|
||||
<Trash2 className="h-3 w-3 shrink-0" />
|
||||
{confirmLabel}
|
||||
</span>
|
||||
) : (
|
||||
confirmLabel
|
||||
);
|
||||
|
||||
return createPortal(
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-5 z-[230] flex justify-center px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto w-[min(92vw,520px)] rounded-2xl border border-white/70 bg-white px-4 py-3 text-sm shadow-[0_4px_14px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.92)] backdrop-blur-2xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{title && (
|
||||
<div className="text-sm font-medium text-gray-950 mb-3">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{message && (
|
||||
<div
|
||||
className={cn("text-xs text-gray-700", title && "mt-1")}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
<PillButton
|
||||
tone="white"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{cancelLabel}
|
||||
</PillButton>
|
||||
<PillButton
|
||||
tone={isDeleteAction ? "danger" : "black"}
|
||||
size="sm"
|
||||
onClick={onConfirm}
|
||||
disabled={resolvedConfirmDisabled}
|
||||
className="h-7 px-3.5 leading-none"
|
||||
aria-busy={confirmBusy}
|
||||
>
|
||||
{resolvedConfirmLabel}
|
||||
</PillButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function progressiveLabel(label: string) {
|
||||
const lower = label.toLowerCase();
|
||||
if (lower.endsWith("e")) return `${label.slice(0, -1)}ing...`;
|
||||
return `${label}ing...`;
|
||||
}
|
||||
|
||||
function completedLabel(label: string) {
|
||||
const lower = label.toLowerCase();
|
||||
if (lower.endsWith("e")) return `${label}d`;
|
||||
return `${label}ed`;
|
||||
}
|
||||
294
frontend/src/app/components/popups/MfaVerificationPopup.tsx
Normal file
294
frontend/src/app/components/popups/MfaVerificationPopup.tsx
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ClipboardEvent,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { supabase } from "@/app/lib/supabase";
|
||||
import { Modal } from "../modals/Modal";
|
||||
|
||||
type MfaFactor = {
|
||||
id: string;
|
||||
friendly_name?: string | null;
|
||||
factor_type: string;
|
||||
};
|
||||
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
const devLog = (...args: Parameters<typeof console.log>) => {
|
||||
if (isDev) console.log(...args);
|
||||
};
|
||||
|
||||
export async function needsMfaVerification() {
|
||||
const { data, error } =
|
||||
await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
|
||||
if (error) throw error;
|
||||
return data.nextLevel === "aal2" && data.currentLevel !== "aal2";
|
||||
}
|
||||
|
||||
interface MfaVerificationPopupProps {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onVerified: () => void;
|
||||
title?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function MfaVerificationPopup({
|
||||
open,
|
||||
onCancel,
|
||||
onVerified,
|
||||
title = "Two-factor verification required",
|
||||
message = "Enter a code from your authenticator app to continue.",
|
||||
}: MfaVerificationPopupProps) {
|
||||
const [factors, setFactors] = useState<MfaFactor[]>([]);
|
||||
const [selectedFactorId, setSelectedFactorId] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const canVerify =
|
||||
!verifying &&
|
||||
!loading &&
|
||||
!!selectedFactorId &&
|
||||
code.trim().length === 6;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
devLog("[mfa-popup] opened");
|
||||
|
||||
async function loadFactors() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setCode("");
|
||||
const { data, error: listError } =
|
||||
await supabase.auth.mfa.listFactors();
|
||||
if (cancelled) return;
|
||||
if (listError) {
|
||||
devLog("[mfa-popup] list factors failed", {
|
||||
error: listError.message,
|
||||
});
|
||||
setError(listError.message);
|
||||
setFactors([]);
|
||||
setSelectedFactorId("");
|
||||
} else {
|
||||
const verified = (data.totp ?? []) as MfaFactor[];
|
||||
devLog("[mfa-popup] factors loaded", {
|
||||
totpCount: verified.length,
|
||||
selectedFactorId: verified[0]?.id ?? null,
|
||||
});
|
||||
setFactors(verified);
|
||||
setSelectedFactorId(verified[0]?.id ?? "");
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
void loadFactors();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
async function verify() {
|
||||
if (!canVerify) return;
|
||||
|
||||
setVerifying(true);
|
||||
setError(null);
|
||||
devLog("[mfa-popup] verifying code", { factorId: selectedFactorId });
|
||||
const { error: verifyError } =
|
||||
await supabase.auth.mfa.challengeAndVerify({
|
||||
factorId: selectedFactorId,
|
||||
code: code.trim(),
|
||||
});
|
||||
setVerifying(false);
|
||||
|
||||
if (verifyError) {
|
||||
devLog("[mfa-popup] verification failed", {
|
||||
error: verifyError.message,
|
||||
});
|
||||
setError(verifyError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
devLog("[mfa-popup] verification succeeded");
|
||||
setCode("");
|
||||
onVerified();
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onCancel}
|
||||
breadcrumbs={[title]}
|
||||
size="sm"
|
||||
className="h-auto min-h-[310px] max-h-[min(92vh,400px)]"
|
||||
cancelAction={{
|
||||
label: "Cancel",
|
||||
onClick: onCancel,
|
||||
disabled: verifying,
|
||||
}}
|
||||
primaryAction={{
|
||||
label: verifying ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Verifying...
|
||||
</span>
|
||||
) : (
|
||||
"Verify"
|
||||
),
|
||||
onClick: () => void verify(),
|
||||
disabled: !canVerify,
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto pb-2 pt-0">
|
||||
<p className="text-sm text-gray-500 pb-6">{message}</p>
|
||||
{loading ? (
|
||||
<div className="flex h-13 items-center justify-center text-sm text-gray-500">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading authenticator...
|
||||
</div>
|
||||
) : factors.length === 0 ? (
|
||||
<p className="rounded-lg bg-gray-100 px-3 py-2 text-sm text-gray-600">
|
||||
No verified authenticator factor is available for this
|
||||
session.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{factors.length > 1 && (
|
||||
<select
|
||||
value={selectedFactorId}
|
||||
onChange={(event) =>
|
||||
setSelectedFactorId(event.target.value)
|
||||
}
|
||||
className="h-9 w-full rounded-lg bg-gray-100 px-3 text-sm text-gray-900 outline-none focus-visible:ring-2 focus-visible:ring-gray-300/45"
|
||||
>
|
||||
{factors.map((factor) => (
|
||||
<option key={factor.id} value={factor.id}>
|
||||
{factor.friendly_name ||
|
||||
"Authenticator app"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<VerificationCodeInput
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
disabled={verifying}
|
||||
autoFocus={open && !loading}
|
||||
onSubmit={() => void verify()}
|
||||
canSubmit={canVerify}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function VerificationCodeInput({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
autoFocus,
|
||||
onSubmit,
|
||||
canSubmit,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
onSubmit?: () => void;
|
||||
canSubmit?: boolean;
|
||||
}) {
|
||||
const inputsRef = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const digits = Array.from({ length: 6 }, (_, index) => value[index] ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocus || disabled) return;
|
||||
const focusTimer = window.setTimeout(() => {
|
||||
const firstEmptyIndex = digits.findIndex((digit) => !digit);
|
||||
inputsRef.current[
|
||||
firstEmptyIndex === -1 ? 0 : firstEmptyIndex
|
||||
]?.focus();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(focusTimer);
|
||||
}, [autoFocus, disabled]);
|
||||
|
||||
function updateDigit(index: number, nextValue: string) {
|
||||
const digit = nextValue.replace(/\D/g, "").slice(-1);
|
||||
const nextDigits = [...digits];
|
||||
nextDigits[index] = digit;
|
||||
onChange(nextDigits.join(""));
|
||||
if (digit && index < inputsRef.current.length - 1) {
|
||||
inputsRef.current[index + 1]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(event: ClipboardEvent<HTMLInputElement>) {
|
||||
event.preventDefault();
|
||||
const pasted = event.clipboardData
|
||||
.getData("text")
|
||||
.replace(/\D/g, "")
|
||||
.slice(0, 6);
|
||||
if (!pasted) return;
|
||||
onChange(pasted);
|
||||
inputsRef.current[Math.min(pasted.length, 6) - 1]?.focus();
|
||||
}
|
||||
|
||||
function handleKeyDown(
|
||||
event: KeyboardEvent<HTMLInputElement>,
|
||||
index: number,
|
||||
) {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (canSubmit) onSubmit?.();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Backspace" && !digits[index] && index > 0) {
|
||||
inputsRef.current[index - 1]?.focus();
|
||||
}
|
||||
if (event.key === "ArrowLeft" && index > 0) {
|
||||
event.preventDefault();
|
||||
inputsRef.current[index - 1]?.focus();
|
||||
}
|
||||
if (event.key === "ArrowRight" && index < digits.length - 1) {
|
||||
event.preventDefault();
|
||||
inputsRef.current[index + 1]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex justify-center gap-2"
|
||||
role="group"
|
||||
aria-label="Six digit verification code"
|
||||
>
|
||||
{digits.map((digit, index) => (
|
||||
<input
|
||||
key={index}
|
||||
ref={(element) => {
|
||||
inputsRef.current[index] = element;
|
||||
}}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete={index === 0 ? "one-time-code" : "off"}
|
||||
value={digit}
|
||||
disabled={disabled}
|
||||
onChange={(event) => updateDigit(index, event.target.value)}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={(event) => handleKeyDown(event, index)}
|
||||
className="h-13 w-12 rounded-lg border border-gray-300 bg-gray-50 text-center text-2xl font-medium font-serif text-gray-950 shadow-none outline-none transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-300/45 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
aria-label={`Verification code digit ${index + 1}`}
|
||||
maxLength={1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
frontend/src/app/components/popups/OwnerOnlyPopup.tsx
Normal file
57
frontend/src/app/components/popups/OwnerOnlyPopup.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"use client";
|
||||
|
||||
import { Lock } from "lucide-react";
|
||||
import { WarningPopup } from "../popups/WarningPopup";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Short headline above the body, e.g. "Owner-only action". */
|
||||
title?: string;
|
||||
/** Sentence describing what the user tried to do. */
|
||||
action?: string;
|
||||
/** Email of the project/resource owner, shown so the user knows who to ask. */
|
||||
ownerEmail?: string | null;
|
||||
/** Override the default message entirely. */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight "you don't have permission" popup shown when a non-owner
|
||||
* attempts an owner-only action (manage people, rename, delete, …) on a
|
||||
* shared project. Replaces the silent 404 the backend would otherwise
|
||||
* return so the user understands why the action didn't go through.
|
||||
*/
|
||||
export function OwnerOnlyPopup({
|
||||
open,
|
||||
onClose,
|
||||
title = "Owner-only action",
|
||||
action,
|
||||
ownerEmail,
|
||||
message,
|
||||
}: Props) {
|
||||
if (!open) return null;
|
||||
|
||||
const body =
|
||||
message ??
|
||||
(action
|
||||
? `Only the project owner can ${action}.`
|
||||
: "Only the project owner can perform this action.");
|
||||
|
||||
return (
|
||||
<WarningPopup
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
message={body}
|
||||
icon={<Lock className="h-3.5 w-3.5 shrink-0 text-red-600" />}
|
||||
>
|
||||
{ownerEmail && (
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
Ask <span className="text-gray-600">{ownerEmail}</span> if
|
||||
you need access.
|
||||
</p>
|
||||
)}
|
||||
</WarningPopup>
|
||||
);
|
||||
}
|
||||
117
frontend/src/app/components/popups/WarningPopup.tsx
Normal file
117
frontend/src/app/components/popups/WarningPopup.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"use client";
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { AlertCircle, X } from "lucide-react";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
|
||||
interface WarningPopupAction {
|
||||
label: ReactNode;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface WarningPopupProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: ReactNode;
|
||||
message?: ReactNode;
|
||||
children?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
primaryAction?: WarningPopupAction;
|
||||
secondaryAction?: WarningPopupAction;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WarningPopup({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
message,
|
||||
children,
|
||||
icon,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
className,
|
||||
}: WarningPopupProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const warningIcon = icon ?? (
|
||||
<AlertCircle className="h-3 w-3 shrink-0 text-red-600" />
|
||||
);
|
||||
|
||||
return createPortal(
|
||||
<div className="pointer-events-none fixed left-1/2 top-5 z-[220] w-[min(92vw,520px)] -translate-x-1/2 px-4">
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-auto flex items-start gap-2 rounded-2xl border border-white/70 bg-red-50/75 px-3 py-2 text-xs shadow-[0_4px_12px_rgba(15,23,42,0.11),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-6px_12px_rgba(255,255,255,0.2)] backdrop-blur-2xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1 self-center text-red-600">
|
||||
{title && (
|
||||
<div className="flex items-center gap-1.5 font-medium mb-1">
|
||||
{warningIcon}
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{message && (
|
||||
<div
|
||||
className={cn(!title && "flex items-start gap-1.5")}
|
||||
>
|
||||
{!title && warningIcon}
|
||||
<span className="min-w-0">{message}</span>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
{(primaryAction || secondaryAction) && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{secondaryAction && (
|
||||
<WarningPopupButton action={secondaryAction} />
|
||||
)}
|
||||
{primaryAction && (
|
||||
<WarningPopupButton
|
||||
action={primaryAction}
|
||||
primary
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="shrink-0 text-red-700 transition-colors hover:text-red-500"
|
||||
aria-label="Dismiss warning"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function WarningPopupButton({
|
||||
action,
|
||||
primary = false,
|
||||
}: {
|
||||
action: WarningPopupAction;
|
||||
primary?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40",
|
||||
primary
|
||||
? "bg-gray-900 text-white hover:bg-gray-700"
|
||||
: "text-gray-700 hover:bg-white/70",
|
||||
)}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ import { WarningPopup } from "@/app/components/popups/WarningPopup";
|
|||
import type { Document } from "@/app/components/shared/types";
|
||||
import { isSpreadsheetFilename } from "@/app/components/shared/types";
|
||||
import type { DocumentVersion } from "@/app/lib/mikeApi";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
import { formatBytes } from "./ProjectPageParts";
|
||||
|
||||
const MIN_DOC_COLUMN_WIDTH = 420;
|
||||
|
|
@ -489,7 +489,6 @@ export function DocumentSidePanel({
|
|||
documentId={doc.id}
|
||||
versionId={selectedVersionId}
|
||||
rounded={false}
|
||||
bordered={false}
|
||||
/>
|
||||
) : (
|
||||
<PdfView
|
||||
|
|
@ -499,7 +498,6 @@ export function DocumentSidePanel({
|
|||
version_id: selectedVersionId,
|
||||
}}
|
||||
rounded={false}
|
||||
bordered={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { FileDirectory } from "../shared/FileDirectory";
|
|||
import { AddUserInput } from "../shared/AddUserInput";
|
||||
import type { Project } from "../shared/types";
|
||||
import type { UserLookupResult } from "@/app/lib/mikeApi";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useAuth } from "@/app/contexts/AuthContext";
|
||||
import { Modal } from "../modals/Modal";
|
||||
import { ModalFieldLabel } from "../modals/ModalFieldLabel";
|
||||
import { ModalTextInput } from "../modals/ModalTextInput";
|
||||
|
|
@ -151,12 +151,12 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
|
|||
breadcrumbs={[
|
||||
"Projects",
|
||||
"New project",
|
||||
step === "details" ? "Details" : "Documents",
|
||||
step === "details" ? "Details" : "Add Documents",
|
||||
]}
|
||||
secondaryAction={
|
||||
step === "documents"
|
||||
? {
|
||||
label: `Upload documents${pendingFiles.length > 0 ? ` (${pendingFiles.length})` : ""}`,
|
||||
label: `Upload${pendingFiles.length > 0 ? ` (${pendingFiles.length})` : ""}`,
|
||||
icon: <Upload className="h-3.5 w-3.5" />,
|
||||
onClick: () => fileInputRef.current?.click(),
|
||||
disabled: loading,
|
||||
|
|
@ -309,7 +309,7 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<FileDirectory
|
||||
standaloneDocs={standaloneDocuments}
|
||||
directoryProjects={dirProjects}
|
||||
|
|
@ -319,6 +319,7 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
|
|||
emptyMessage="No existing documents"
|
||||
searchable
|
||||
searchAutoFocus
|
||||
showProjectTabs
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ import {
|
|||
AddDocumentsModal,
|
||||
invalidateDirectoryCache,
|
||||
} from "@/app/components/modals/AddDocumentsModal";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useAuth } from "@/app/contexts/AuthContext";
|
||||
import { WarningPopup } from "@/app/components/popups/WarningPopup";
|
||||
import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { PRACTICE_OPTIONS } from "../workflows/practices";
|
||||
import { ModalSelect, type ModalSelectOption } from "../modals/ModalSelect";
|
||||
import { ModalTextInput } from "../modals/ModalTextInput";
|
||||
|
||||
const OPTION_NONE = "__none__";
|
||||
const OPTION_OTHER = "Other";
|
||||
|
||||
interface ProjectPracticeFieldProps {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ProjectPracticeField({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: ProjectPracticeFieldProps) {
|
||||
const selectedOption = useMemo(() => {
|
||||
if (!value.trim()) return OPTION_NONE;
|
||||
return (PRACTICE_OPTIONS as readonly string[]).includes(value)
|
||||
? value
|
||||
: OPTION_OTHER;
|
||||
}, [value]);
|
||||
const customValue =
|
||||
selectedOption === OPTION_OTHER && value !== OPTION_OTHER ? value : "";
|
||||
const options = useMemo<ModalSelectOption[]>(
|
||||
() => [
|
||||
{ value: OPTION_NONE, label: "None" },
|
||||
...PRACTICE_OPTIONS,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
function handleSelect(option: string) {
|
||||
if (option === OPTION_NONE) {
|
||||
onChange("");
|
||||
return;
|
||||
}
|
||||
if (option === OPTION_OTHER) {
|
||||
onChange(OPTION_OTHER);
|
||||
return;
|
||||
}
|
||||
onChange(option);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<ModalSelect
|
||||
id={id}
|
||||
value={selectedOption}
|
||||
options={options}
|
||||
onChange={handleSelect}
|
||||
placeholder="Select practice"
|
||||
disabled={disabled}
|
||||
/>
|
||||
{selectedOption === OPTION_OTHER && (
|
||||
<ModalTextInput
|
||||
type="text"
|
||||
value={customValue}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder="Enter practice..."
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -34,8 +34,8 @@ import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
|
|||
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
|
||||
import { PeopleModal } from "@/app/components/modals/PeopleModal";
|
||||
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import { useAuth } from "@/app/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/app/contexts/UserProfileContext";
|
||||
import { ProjectDetailsModal } from "./ProjectDetailsModal";
|
||||
import {
|
||||
ProjectPageHeader,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
deleteProject,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useAuth } from "@/app/contexts/AuthContext";
|
||||
import type { Project } from "@/app/components/shared/types";
|
||||
import { NewProjectModal } from "./NewProjectModal";
|
||||
import { ProjectDetailsModal } from "./ProjectDetailsModal";
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import { UserProfileProvider } from "@/contexts/UserProfileContext";
|
||||
import { AuthProvider } from "@/app/contexts/AuthContext";
|
||||
import { UserProfileProvider } from "@/app/contexts/UserProfileContext";
|
||||
import { MfaLoginGate } from "@/app/components/shared/MfaLoginGate";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
123
frontend/src/app/components/shared/AddUserInput.tsx
Normal file
123
frontend/src/app/components/shared/AddUserInput.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
import { Loader2, UserPlus } from "lucide-react";
|
||||
import {
|
||||
lookupUserByEmail,
|
||||
type UserLookupResult,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
interface AddUserInputProps {
|
||||
onAdd: (user: UserLookupResult) => Promise<void> | void;
|
||||
validateEmail?: (email: string) => Promise<string | null> | string | null;
|
||||
busy?: boolean;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
submitLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AddUserInput({
|
||||
onAdd,
|
||||
validateEmail,
|
||||
busy = false,
|
||||
placeholder = "Add by email...",
|
||||
autoFocus = false,
|
||||
submitLabel = "Add user",
|
||||
className,
|
||||
}: AddUserInputProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const trimmedEmail = input.trim().toLowerCase();
|
||||
const showAddButton = trimmedEmail.length > 0;
|
||||
|
||||
async function commitUser() {
|
||||
const email = trimmedEmail;
|
||||
if (!email || busy || checking) return;
|
||||
if (!EMAIL_RE.test(email)) {
|
||||
setError("Enter a valid email.");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setChecking(true);
|
||||
try {
|
||||
const validationError = await validateEmail?.(email);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await lookupUserByEmail(email);
|
||||
if (!user.exists) {
|
||||
setError(`${email} does not belong to a Mike user.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await onAdd(user);
|
||||
setInput("");
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Could not add this user. Try again.",
|
||||
);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === "Enter" || event.key === ",") {
|
||||
event.preventDefault();
|
||||
void commitUser();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-10 items-center gap-2 rounded-xl border border-white/70 bg-white/55 px-3 py-1.5 shadow-[0_3px_9px_rgba(15,23,42,0.04),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-xl transition-colors focus-within:bg-white/70",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
<input
|
||||
type="email"
|
||||
value={input}
|
||||
onChange={(event) => {
|
||||
setInput(event.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-gray-700 outline-none placeholder:text-gray-400"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
{showAddButton && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => void commitUser()}
|
||||
disabled={busy || checking}
|
||||
title={submitLabel}
|
||||
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-full border border-blue-500/35 bg-blue-600/90 px-2.5 text-[11px] font-medium leading-none text-white shadow-[0_3px_9px_rgba(37,99,235,0.10),inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-1px_0_rgba(255,255,255,0.16),inset_0_-4px_9px_rgba(29,78,216,0.2)] backdrop-blur-xl transition-colors hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{(busy || checking) && (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
)}
|
||||
Add
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="mt-1.5 text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { useState, useEffect, useMemo } from "react";
|
|||
import {
|
||||
PanelLeft,
|
||||
MessageSquare,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Table2,
|
||||
Library,
|
||||
|
|
@ -11,16 +12,16 @@ import {
|
|||
ChevronsUpDown,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import { useAuth } from "@/app/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/app/contexts/UserProfileContext";
|
||||
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { MikeIcon } from "@/components/chat/mike-icon";
|
||||
import { MikeIcon } from "@/app/components/chat/mike-icon";
|
||||
import { SidebarChatItem } from "@/app/components/shared/SidebarChatItem";
|
||||
import { listProjects } from "@/app/lib/mikeApi";
|
||||
import type { Project } from "@/app/components/shared/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/assistant", label: "Assistant", icon: MessageSquare },
|
||||
|
|
@ -85,9 +86,10 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
});
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) setShouldAnimate(true);
|
||||
}, [isOpen]);
|
||||
const handleToggle = () => {
|
||||
if (isOpen) setShouldAnimate(true);
|
||||
onToggle();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => setIsDropdownOpen(false);
|
||||
|
|
@ -121,113 +123,222 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
isOpen
|
||||
? "w-64 h-[calc(100dvh-1rem)] md:h-[calc(100dvh-1.5rem)] bg-white/65"
|
||||
: "max-md:hidden w-14 md:h-[calc(100dvh-1.5rem)] md:bg-white/65 h-auto bg-transparent pointer-events-none md:pointer-events-auto",
|
||||
"my-2 ml-2 mr-0 md:my-3 md:ml-3 md:mr-0 rounded-2xl border border-white/70 shadow-[0_-1px_6px_rgba(15,23,42,0.034),0_4px_9px_rgba(15,23,42,0.074),inset_0_1px_0_rgba(255,255,255,0.85)] backdrop-blur-2xl overflow-visible",
|
||||
"flex flex-col transition-all duration-300 absolute md:relative z-[99]",
|
||||
<>
|
||||
{/* Mobile: tapping outside the expanded sidebar closes it. The
|
||||
sidebar (z-[99]) sits above this scrim (z-[98]); md+ is
|
||||
unaffected since the sidebar is part of the layout there. */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[98] bg-gray-300/20 md:hidden"
|
||||
onClick={handleToggle}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{/* Toggle + Logo */}
|
||||
<div
|
||||
className={`items-center justify-between px-2.5 py-3 ${
|
||||
!isOpen ? "hidden md:flex" : "flex"
|
||||
}`}
|
||||
className={cn(
|
||||
isOpen
|
||||
? "w-64 h-[calc(100dvh-1rem)] md:h-[calc(100dvh-1.5rem)] bg-white/65"
|
||||
: "max-md:hidden w-14 md:h-[calc(100dvh-1.5rem)] md:bg-white/65 h-auto bg-transparent pointer-events-none md:pointer-events-auto",
|
||||
"my-2 ml-2 mr-0 md:my-3 md:ml-3 md:mr-0 rounded-2xl border border-white/70 shadow-[0_-1px_6px_rgba(15,23,42,0.034),0_4px_9px_rgba(15,23,42,0.074),inset_0_1px_0_rgba(255,255,255,0.85)] backdrop-blur-2xl overflow-visible",
|
||||
"flex flex-col transition-all duration-300 absolute md:relative z-[99]",
|
||||
)}
|
||||
>
|
||||
{/* Toggle + Logo */}
|
||||
<div
|
||||
className={`items-center justify-between px-2.5 py-3 ${
|
||||
!isOpen ? "hidden md:flex" : "flex"
|
||||
}`}
|
||||
>
|
||||
{isOpen && (
|
||||
<div className="px-2">
|
||||
<Link
|
||||
href="/assistant"
|
||||
className="flex items-center gap-1.5 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<MikeIcon size={22} />
|
||||
<span
|
||||
className={`text-2xl font-light font-serif ${
|
||||
shouldAnimate ? "sidebar-fade-in" : ""
|
||||
}`}
|
||||
>
|
||||
Mike
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className={cn(
|
||||
"h-9 w-9 p-2.5 items-center flex transition-colors",
|
||||
"rounded-md hover:bg-gray-100",
|
||||
)}
|
||||
title={isOpen ? "Close sidebar" : "Open sidebar"}
|
||||
>
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
{NAV_ITEMS.map(({ href, label, icon: Icon }) => {
|
||||
const isActive =
|
||||
href === "/assistant"
|
||||
? pathname === href
|
||||
: href === "/projects"
|
||||
? pathname === href
|
||||
: pathname === href ||
|
||||
pathname.startsWith(href + "/");
|
||||
return (
|
||||
<div key={href} className="py-0.5 px-2.5">
|
||||
<button
|
||||
onClick={() => router.push(href)}
|
||||
title={!isOpen ? label : ""}
|
||||
className={cn(
|
||||
"w-full h-9 flex items-center gap-3 px-2.5 py-2 rounded-md transition-colors text-left",
|
||||
isActive
|
||||
? "bg-gray-200/60 text-gray-900"
|
||||
: "text-gray-700 hover:bg-gray-100",
|
||||
!isOpen ? "hidden md:flex" : "flex",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={`h-4 w-4 flex-shrink-0 ${
|
||||
isActive
|
||||
? "text-gray-900"
|
||||
: "text-black"
|
||||
}`}
|
||||
/>
|
||||
{isOpen && (
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
shouldAnimate
|
||||
? "sidebar-fade-in-2"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{isOpen && (
|
||||
<div className="px-2">
|
||||
<Link
|
||||
href="/assistant"
|
||||
className="flex items-center gap-1.5 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<MikeIcon size={22} />
|
||||
<span
|
||||
className={`text-2xl font-light font-serif ${
|
||||
<div className="mt-4 flex-1 min-h-0 flex flex-col gap-4">
|
||||
{/* Recent Projects */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setProjectsCollapsed((v) => !v)}
|
||||
className={`mb-2 flex w-full items-center justify-between px-5 text-xs font-semibold text-gray-500 transition-colors hover:text-gray-700 ${
|
||||
shouldAnimate ? "sidebar-fade-in" : ""
|
||||
}`}
|
||||
>
|
||||
Mike
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"h-9 w-9 p-2.5 items-center flex transition-colors",
|
||||
"rounded-md hover:bg-gray-100",
|
||||
)}
|
||||
title={isOpen ? "Close sidebar" : "Open sidebar"}
|
||||
>
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
{NAV_ITEMS.map(({ href, label, icon: Icon }) => {
|
||||
const isActive =
|
||||
href === "/assistant"
|
||||
? pathname === href
|
||||
: href === "/projects"
|
||||
? pathname === href
|
||||
: pathname === href ||
|
||||
pathname.startsWith(href + "/");
|
||||
return (
|
||||
<div key={href} className="py-0.5 px-2.5">
|
||||
<button
|
||||
onClick={() => router.push(href)}
|
||||
title={!isOpen ? label : ""}
|
||||
className={cn(
|
||||
"w-full h-9 flex items-center gap-3 px-2.5 py-2 rounded-md transition-colors text-left",
|
||||
isActive
|
||||
? "bg-gray-200/60 text-gray-900"
|
||||
: "text-gray-700 hover:bg-gray-100",
|
||||
!isOpen ? "hidden md:flex" : "flex",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={`h-4 w-4 flex-shrink-0 ${
|
||||
isActive ? "text-gray-900" : "text-black"
|
||||
}`}
|
||||
/>
|
||||
{isOpen && (
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
shouldAnimate ? "sidebar-fade-in-2" : ""
|
||||
<span>Recent Projects</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 transition-transform ${
|
||||
projectsCollapsed ? "-rotate-90" : ""
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
/>
|
||||
</button>
|
||||
{!projectsCollapsed && (
|
||||
<>
|
||||
{!recentProjects ? (
|
||||
<div className="space-y-1 px-2.5">
|
||||
{[50, 65, 45].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-9 flex items-center px-3 rounded-md"
|
||||
>
|
||||
<div
|
||||
className="h-3 bg-gray-200 rounded animate-pulse"
|
||||
style={{
|
||||
width: `${w}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : recentProjects.length === 0 ? (
|
||||
<div
|
||||
className={`px-5 py-2 text-xs text-gray-500 ${
|
||||
shouldAnimate
|
||||
? "sidebar-fade-in-2"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
No projects yet
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`space-y-1 px-2.5 ${
|
||||
shouldAnimate
|
||||
? "sidebar-fade-in-2"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{recentProjects.map((project) => {
|
||||
const isActive =
|
||||
pathname ===
|
||||
`/projects/${project.id}` ||
|
||||
pathname.startsWith(
|
||||
`/projects/${project.id}/`,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={project.id}
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/projects/${project.id}`,
|
||||
)
|
||||
}
|
||||
title={project.name}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs transition-colors",
|
||||
isActive
|
||||
? "bg-gray-200/60 text-gray-900"
|
||||
: "text-gray-700 hover:bg-gray-100",
|
||||
)}
|
||||
>
|
||||
{isActive ? (
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-600" />
|
||||
) : (
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-gray-600" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{project.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="mt-4 flex-1 min-h-0 flex flex-col gap-4">
|
||||
{/* Recent Projects */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setProjectsCollapsed((v) => !v)}
|
||||
className={`mb-2 flex w-full items-center justify-between px-5 text-xs font-semibold text-gray-500 transition-colors hover:text-gray-700 ${
|
||||
shouldAnimate ? "sidebar-fade-in" : ""
|
||||
}`}
|
||||
>
|
||||
<span>Recent Projects</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 transition-transform ${
|
||||
projectsCollapsed ? "-rotate-90" : ""
|
||||
{/* Assistant History */}
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<button
|
||||
onClick={() => setHistoryCollapsed((v) => !v)}
|
||||
className={`mb-2 flex w-full items-center justify-between px-5 text-xs font-semibold text-gray-500 transition-colors hover:text-gray-700 ${
|
||||
shouldAnimate ? "sidebar-fade-in" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{!projectsCollapsed && (
|
||||
<>
|
||||
{!recentProjects ? (
|
||||
>
|
||||
<span>Assistant History</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 transition-transform ${
|
||||
historyCollapsed ? "-rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={`overflow-y-auto flex-1 ${
|
||||
historyCollapsed ? "hidden" : ""
|
||||
}`}
|
||||
>
|
||||
{!chats ? (
|
||||
<div className="space-y-1 px-2.5">
|
||||
{[50, 65, 45].map((w, i) => (
|
||||
{[40, 60, 50, 70, 45].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-9 flex items-center px-3 rounded-md"
|
||||
|
|
@ -239,220 +350,142 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : recentProjects.length === 0 ? (
|
||||
) : chats.length === 0 ? (
|
||||
<div
|
||||
className={`px-5 py-2 text-xs text-gray-500 ${
|
||||
className={`text-xs text-gray-500 py-2 px-5 ${
|
||||
shouldAnimate
|
||||
? "sidebar-fade-in-2"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
No projects yet
|
||||
No chats yet
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`space-y-1 px-2.5 ${
|
||||
shouldAnimate
|
||||
? "sidebar-fade-in-2"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{recentProjects.map((project) => {
|
||||
const isActive =
|
||||
pathname ===
|
||||
`/projects/${project.id}` ||
|
||||
pathname.startsWith(
|
||||
`/projects/${project.id}/`,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={project.id}
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/projects/${project.id}`,
|
||||
)
|
||||
<>
|
||||
<div
|
||||
className={`space-y-1 px-2.5 ${
|
||||
shouldAnimate
|
||||
? "sidebar-fade-in-2"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{chats.map((chat) => (
|
||||
<SidebarChatItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
isActive={
|
||||
routeChatId === chat.id
|
||||
}
|
||||
title={project.name}
|
||||
projectName={
|
||||
chat.project_id
|
||||
? projectNames[
|
||||
chat
|
||||
.project_id
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
onSelect={() => {
|
||||
setCurrentChatId(
|
||||
chat.id,
|
||||
);
|
||||
router.push(
|
||||
chat.project_id
|
||||
? `/projects/${chat.project_id}/assistant/chat/${chat.id}`
|
||||
: `/assistant/chat/${chat.id}`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasMoreChats && (
|
||||
<div className="px-2.5 pt-1">
|
||||
<button
|
||||
onClick={loadMoreChats}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs transition-colors",
|
||||
isActive
|
||||
? "bg-gray-200/60 text-gray-900"
|
||||
: "text-gray-700 hover:bg-gray-100",
|
||||
"flex h-8 w-full items-center justify-start rounded-md px-3 text-left text-xs font-medium text-gray-500 transition-colors hover:text-gray-700",
|
||||
"hover:bg-gray-100",
|
||||
)}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-600" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{project.name}
|
||||
</span>
|
||||
Load more
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assistant History */}
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<button
|
||||
onClick={() => setHistoryCollapsed((v) => !v)}
|
||||
className={`mb-2 flex w-full items-center justify-between px-5 text-xs font-semibold text-gray-500 transition-colors hover:text-gray-700 ${
|
||||
shouldAnimate ? "sidebar-fade-in" : ""
|
||||
}`}
|
||||
>
|
||||
<span>Assistant History</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 transition-transform ${
|
||||
historyCollapsed ? "-rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={`overflow-y-auto flex-1 ${
|
||||
historyCollapsed ? "hidden" : ""
|
||||
}`}
|
||||
>
|
||||
{!chats ? (
|
||||
<div className="space-y-1 px-2.5">
|
||||
{[40, 60, 50, 70, 45].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-9 flex items-center px-3 rounded-md"
|
||||
>
|
||||
<div
|
||||
className="h-3 bg-gray-200 rounded animate-pulse"
|
||||
style={{ width: `${w}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{/* User Profile */}
|
||||
<div className="mt-auto p-1">
|
||||
{user && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() =>
|
||||
setIsDropdownOpen(!isDropdownOpen)
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center transition-colors w-full px-2.5 py-3 border-t",
|
||||
"rounded-xl border-white/60",
|
||||
!isOpen ? "hidden md:flex" : "",
|
||||
pathname === "/account" || isDropdownOpen
|
||||
? "bg-gray-200/60"
|
||||
: "hover:bg-gray-100",
|
||||
)}
|
||||
title={!isOpen ? user.email : undefined}
|
||||
>
|
||||
<div className="h-6.5 w-6.5 flex-shrink-0 rounded-full bg-gray-700 flex items-center justify-center text-white text-sm font-medium font-serif">
|
||||
{getUserInitials(user.email)}
|
||||
</div>
|
||||
) : chats.length === 0 ? (
|
||||
<div
|
||||
className={`text-xs text-gray-500 py-2 px-5 ${
|
||||
shouldAnimate ? "sidebar-fade-in-2" : ""
|
||||
}`}
|
||||
>
|
||||
No chats yet
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`space-y-1 px-2.5 ${
|
||||
className={`text-left flex-1 min-w-0 pl-3 flex items-center justify-between gap-2 ${
|
||||
shouldAnimate
|
||||
? "sidebar-fade-in-2"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{chats.map((chat) => (
|
||||
<SidebarChatItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
isActive={
|
||||
routeChatId === chat.id
|
||||
}
|
||||
projectName={
|
||||
chat.project_id
|
||||
? projectNames[
|
||||
chat.project_id
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
onSelect={() => {
|
||||
setCurrentChatId(chat.id);
|
||||
router.push(
|
||||
chat.project_id
|
||||
? `/projects/${chat.project_id}/assistant/chat/${chat.id}`
|
||||
: `/assistant/chat/${chat.id}`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasMoreChats && (
|
||||
<div className="px-2.5 pt-1">
|
||||
<button
|
||||
onClick={loadMoreChats}
|
||||
className={cn(
|
||||
"flex h-8 w-full items-center justify-start rounded-md px-3 text-left text-xs font-medium text-gray-500 transition-colors hover:text-gray-700",
|
||||
"hover:bg-gray-100",
|
||||
)}
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 leading-none">
|
||||
{getDisplayName()}
|
||||
</div>
|
||||
<div className="text-[12px] text-gray-500 leading-none">
|
||||
{getUserTier()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ChevronsUpDown className="h-4 w-4 flex-shrink-0 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* User Profile */}
|
||||
<div className="mt-auto p-1">
|
||||
{user && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
className={cn(
|
||||
"flex items-center transition-colors w-full px-2.5 py-3 border-t",
|
||||
"rounded-xl border-white/60",
|
||||
!isOpen ? "hidden md:flex" : "",
|
||||
pathname === "/account" || isDropdownOpen
|
||||
? "bg-gray-200/60"
|
||||
: "hover:bg-gray-100",
|
||||
)}
|
||||
title={!isOpen ? user.email : undefined}
|
||||
>
|
||||
<div className="h-6.5 w-6.5 flex-shrink-0 rounded-full bg-gray-700 flex items-center justify-center text-white text-sm font-medium font-serif">
|
||||
{getUserInitials(user.email)}
|
||||
</div>
|
||||
{isOpen && (
|
||||
{isDropdownOpen && (
|
||||
<div
|
||||
className={`text-left flex-1 min-w-0 pl-3 flex items-center justify-between gap-2 ${
|
||||
shouldAnimate ? "sidebar-fade-in-2" : ""
|
||||
}`}
|
||||
className={cn(
|
||||
"absolute bottom-full left-0 z-50 mb-1 p-1 whitespace-nowrap",
|
||||
isOpen ? "right-0" : "w-56",
|
||||
"bg-white/80 rounded-xl shadow-[0_6px_17px_rgba(15,23,42,0.1)] border border-white/70 backdrop-blur-xl",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 leading-none">
|
||||
{getDisplayName()}
|
||||
</div>
|
||||
<div className="text-[12px] text-gray-500 leading-none">
|
||||
{getUserTier()}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronsUpDown className="h-4 w-4 flex-shrink-0 text-gray-400" />
|
||||
<button
|
||||
onClick={() => {
|
||||
router.push("/account");
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full px-4 py-2 text-left text-sm text-gray-700 flex items-center gap-2 rounded-md",
|
||||
"hover:bg-white/70",
|
||||
)}
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
Account Settings
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-full left-0 z-50 mb-1 p-1 whitespace-nowrap",
|
||||
isOpen ? "right-0" : "w-56",
|
||||
"bg-white/80 rounded-xl shadow-[0_6px_17px_rgba(15,23,42,0.1)] border border-white/70 backdrop-blur-xl",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
router.push("/account");
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full px-4 py-2 text-left text-sm text-gray-700 flex items-center gap-2 rounded-md",
|
||||
"hover:bg-white/70",
|
||||
)}
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
Account Settings
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@ import {
|
|||
ChevronRight,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Trash2,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import type { Document, Project } from "./types";
|
||||
import { VersionChip } from "./VersionChip";
|
||||
import { FileTypeIcon } from "./FileTypeIcon";
|
||||
import { SearchBar } from "@/components/ui/search-bar";
|
||||
import { SearchBar } from "@/app/components/ui/search-bar";
|
||||
import { ModalSegmentedToggle } from "../modals/ModalSegmentedToggle";
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
if (!iso) return null;
|
||||
|
|
@ -37,13 +37,12 @@ interface FileDirectoryProps {
|
|||
allowMultiple?: boolean;
|
||||
forceExpanded?: boolean;
|
||||
emptyMessage?: string;
|
||||
heading?: string;
|
||||
onDelete?: (ids: string[]) => void | Promise<void>;
|
||||
uploadingFilenames?: string[];
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
searchAutoFocus?: boolean;
|
||||
searchNoResultsMessage?: string;
|
||||
showProjectTabs?: boolean;
|
||||
}
|
||||
|
||||
export function FileDirectory({
|
||||
|
|
@ -55,21 +54,21 @@ export function FileDirectory({
|
|||
allowMultiple = true,
|
||||
forceExpanded = false,
|
||||
emptyMessage = "No documents yet",
|
||||
heading = "Documents",
|
||||
onDelete,
|
||||
uploadingFilenames = [],
|
||||
searchable = false,
|
||||
searchPlaceholder = "Search...",
|
||||
searchAutoFocus = false,
|
||||
searchNoResultsMessage = "No matches found",
|
||||
showProjectTabs,
|
||||
}: FileDirectoryProps) {
|
||||
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [selectedTab, setSelectedTab] = useState<"files" | "projects">(
|
||||
"files",
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const selectedCount = selectedIds.size;
|
||||
const q = search.trim().toLowerCase();
|
||||
const visibleStandaloneDocs = q
|
||||
? standaloneDocs.filter((doc) => doc.filename.toLowerCase().includes(q))
|
||||
|
|
@ -104,30 +103,22 @@ export function FileDirectory({
|
|||
);
|
||||
})
|
||||
: directoryProjects;
|
||||
|
||||
async function handleDelete() {
|
||||
if (!onDelete || selectedCount === 0 || deleting) return;
|
||||
const ids = Array.from(selectedIds);
|
||||
setDeleting(true);
|
||||
try {
|
||||
await onDelete(ids);
|
||||
const next = new Set(selectedIds);
|
||||
ids.forEach((id) => next.delete(id));
|
||||
onChange(next);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
const showTabs = showProjectTabs ?? directoryProjects.length > 0;
|
||||
const activeTab = showTabs ? selectedTab : "files";
|
||||
const hasVisibleFiles =
|
||||
visibleStandaloneDocs.length > 0 ||
|
||||
visibleUploadingFilenames.length > 0;
|
||||
const hasVisibleProjects = visibleDirectoryProjects.length > 0;
|
||||
const activeTabHasNoResults =
|
||||
q &&
|
||||
((activeTab === "files" && !hasVisibleFiles) ||
|
||||
(activeTab === "projects" && !hasVisibleProjects));
|
||||
|
||||
const allDocs = [
|
||||
...standaloneDocs,
|
||||
...directoryProjects.flatMap((p) => p.documents ?? []),
|
||||
];
|
||||
|
||||
const allStandaloneSelected =
|
||||
visibleStandaloneDocs.length > 0 &&
|
||||
visibleStandaloneDocs.every((d) => selectedIds.has(d.id));
|
||||
|
||||
function toggle(docId: string) {
|
||||
if (!allowMultiple) {
|
||||
onChange(new Set([docId]));
|
||||
|
|
@ -142,18 +133,6 @@ export function FileDirectory({
|
|||
onChange(next);
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
if (allStandaloneSelected) {
|
||||
const next = new Set(selectedIds);
|
||||
visibleStandaloneDocs.forEach((d) => next.delete(d.id));
|
||||
onChange(next);
|
||||
} else {
|
||||
const next = new Set(selectedIds);
|
||||
visibleStandaloneDocs.forEach((d) => next.add(d.id));
|
||||
onChange(next);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFolder(projectId: string) {
|
||||
if (forceExpanded) return;
|
||||
setExpandedProjects((prev) => {
|
||||
|
|
@ -169,23 +148,23 @@ export function FileDirectory({
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-px">
|
||||
<div className="flex min-h-0 flex-1 flex-col space-y-2">
|
||||
{searchable && (
|
||||
<SearchBar
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={searchPlaceholder}
|
||||
autoFocus={searchAutoFocus}
|
||||
wrapperClassName="mb-2"
|
||||
wrapperClassName={showTabs ? "mb-4" : "mb-3"}
|
||||
/>
|
||||
)}
|
||||
{/* Documents header skeleton */}
|
||||
<div className="flex items-center justify-between rounded-md px-2 py-2">
|
||||
<div className="h-3 w-20 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-3 w-12 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
{/* File rows skeleton */}
|
||||
<div>
|
||||
{showTabs && (
|
||||
<FileDirectoryTabs
|
||||
activeTab={activeTab}
|
||||
onChange={setSelectedTab}
|
||||
/>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{[60, 45, 75, 55, 40].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
|
|
@ -210,255 +189,269 @@ export function FileDirectory({
|
|||
uploadingFilenames.length === 0
|
||||
) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex min-h-0 flex-1 flex-col space-y-2">
|
||||
{searchable && (
|
||||
<SearchBar
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={searchPlaceholder}
|
||||
autoFocus={searchAutoFocus}
|
||||
wrapperClassName="mb-2"
|
||||
wrapperClassName={showTabs ? "mb-4" : "mb-3"}
|
||||
/>
|
||||
)}
|
||||
<p className="text-center text-sm text-gray-400 py-8">
|
||||
{emptyMessage}
|
||||
</p>
|
||||
{showTabs && (
|
||||
<FileDirectoryTabs
|
||||
activeTab={activeTab}
|
||||
onChange={setSelectedTab}
|
||||
/>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<p className="text-center text-sm text-gray-400 py-8">
|
||||
{emptyMessage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-sm">
|
||||
<div className="flex min-h-0 flex-1 flex-col space-y-2 rounded-sm">
|
||||
{searchable && (
|
||||
<SearchBar
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={searchPlaceholder}
|
||||
autoFocus={searchAutoFocus}
|
||||
wrapperClassName={showTabs ? "mb-4" : "mb-3"}
|
||||
/>
|
||||
)}
|
||||
{q &&
|
||||
visibleStandaloneDocs.length === 0 &&
|
||||
visibleDirectoryProjects.length === 0 &&
|
||||
visibleUploadingFilenames.length === 0 ? (
|
||||
<p className="text-center text-sm text-gray-400 py-8">
|
||||
{searchNoResultsMessage}
|
||||
</p>
|
||||
{showTabs && (
|
||||
<FileDirectoryTabs
|
||||
activeTab={activeTab}
|
||||
onChange={setSelectedTab}
|
||||
/>
|
||||
)}
|
||||
{activeTabHasNoResults ? (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<p className="text-center text-sm text-gray-400 py-8">
|
||||
{searchNoResultsMessage}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{(visibleStandaloneDocs.length > 0 ||
|
||||
visibleUploadingFilenames.length > 0 ||
|
||||
(onDelete && selectedCount > 0)) && (
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<p className="text-xs font-medium text-gray-400">
|
||||
{heading}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{onDelete && selectedCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="inline-flex items-center gap-1 text-xs text-red-500 hover:text-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
{visibleStandaloneDocs.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAll}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
{allStandaloneSelected
|
||||
? "Deselect all"
|
||||
: "Select all"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{visibleUploadingFilenames.map((filename) => (
|
||||
<div
|
||||
key={`uploading-${filename}`}
|
||||
className="w-full flex items-center gap-2 px-2 py-2 text-xs text-left"
|
||||
>
|
||||
<span className="shrink-0 h-3.5 w-3.5 rounded border border-gray-300" />
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400 shrink-0" />
|
||||
<span className="flex-1 truncate text-gray-400">
|
||||
{filename}
|
||||
</span>
|
||||
<span className="shrink-0 text-gray-300">
|
||||
Uploading
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{visibleStandaloneDocs.map((doc) => {
|
||||
const selected = selectedIds.has(doc.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={doc.id}
|
||||
onClick={() => toggle(doc.id)}
|
||||
className={`w-full rounded-md flex items-center gap-2 px-2 py-2 text-xs transition-all text-left ${
|
||||
selected
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-100/70"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
|
||||
selected
|
||||
? "bg-gray-900 border-gray-900"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{activeTab === "files" && (
|
||||
<>
|
||||
{visibleUploadingFilenames.map((filename) => (
|
||||
<div
|
||||
key={`uploading-${filename}`}
|
||||
className="w-full flex items-center gap-2 px-2 py-2 text-xs text-left"
|
||||
>
|
||||
{selected && (
|
||||
<Check className="h-2.5 w-2.5 text-white" />
|
||||
)}
|
||||
</span>
|
||||
<DocFileIcon fileType={doc.file_type} />
|
||||
<span
|
||||
className={`flex-1 truncate ${
|
||||
selected
|
||||
? "text-gray-900"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{doc.filename}
|
||||
</span>
|
||||
<VersionChip
|
||||
n={
|
||||
doc.active_version_number ??
|
||||
doc.latest_version_number
|
||||
}
|
||||
/>
|
||||
{doc.created_at && (
|
||||
<span className="shrink-0 text-gray-300">
|
||||
{formatDate(doc.created_at)}
|
||||
<span className="shrink-0 h-3.5 w-3.5 rounded border border-gray-300" />
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400 shrink-0" />
|
||||
<span className="flex-1 truncate text-gray-400">
|
||||
{filename}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{visibleStandaloneDocs.length > 0 &&
|
||||
visibleDirectoryProjects.length > 0 && (
|
||||
<div className="py-2 px-2 mt-2">
|
||||
<p className="text-xs font-medium text-gray-400">
|
||||
Projects
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleDirectoryProjects.map((project) => {
|
||||
const isExpanded =
|
||||
forceExpanded ||
|
||||
!!q ||
|
||||
expandedProjects.has(project.id);
|
||||
const docs = project.documents ?? [];
|
||||
return (
|
||||
<div key={project.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFolder(project.id)}
|
||||
className="w-full rounded-md flex items-center gap-2 px-2 py-2 text-xs transition-all text-left hover:bg-gray-100/70"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3 w-3 text-gray-400 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 text-gray-400 shrink-0" />
|
||||
)}
|
||||
{isExpanded ? (
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
) : (
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
)}
|
||||
<span className="flex-1 truncate font-medium text-gray-700">
|
||||
{project.name}
|
||||
{project.cm_number && (
|
||||
<span className="ml-1 font-normal text-gray-400">
|
||||
(#{project.cm_number})
|
||||
<span className="shrink-0 text-gray-300">
|
||||
Uploading
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{visibleStandaloneDocs.map((doc) => {
|
||||
const selected = selectedIds.has(doc.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={doc.id}
|
||||
onClick={() => toggle(doc.id)}
|
||||
className={`w-full rounded-md flex items-center gap-2 px-2 py-2 text-xs transition-all text-left ${
|
||||
selected
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-100/70"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
|
||||
selected
|
||||
? "bg-gray-900 border-gray-900"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{selected && (
|
||||
<Check className="h-2.5 w-2.5 text-white" />
|
||||
)}
|
||||
</span>
|
||||
<DocFileIcon fileType={doc.file_type} />
|
||||
<span
|
||||
className={`flex-1 truncate ${
|
||||
selected
|
||||
? "text-gray-900"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{doc.filename}
|
||||
</span>
|
||||
<VersionChip
|
||||
n={
|
||||
doc.active_version_number ??
|
||||
doc.latest_version_number
|
||||
}
|
||||
/>
|
||||
{doc.created_at && (
|
||||
<span className="shrink-0 text-gray-300">
|
||||
{formatDate(doc.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 shrink-0">
|
||||
{docs.length}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{docs.length === 0 ? (
|
||||
<p className="pl-7 py-1 text-xs text-gray-400">
|
||||
Empty
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!q &&
|
||||
visibleStandaloneDocs.length === 0 &&
|
||||
visibleUploadingFilenames.length === 0 && (
|
||||
<p className="text-center text-sm text-gray-400 py-8">
|
||||
{emptyMessage}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "projects" &&
|
||||
visibleDirectoryProjects.map((project) => {
|
||||
const isExpanded =
|
||||
forceExpanded ||
|
||||
!!q ||
|
||||
expandedProjects.has(project.id);
|
||||
const docs = project.documents ?? [];
|
||||
return (
|
||||
<div key={project.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toggleFolder(project.id)
|
||||
}
|
||||
className="w-full rounded-md flex items-center gap-2 px-2 py-2 text-xs transition-all text-left hover:bg-gray-100/70"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3 w-3 text-gray-400 shrink-0" />
|
||||
) : (
|
||||
docs.map((doc) => {
|
||||
const selected =
|
||||
selectedIds.has(doc.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={doc.id}
|
||||
onClick={() =>
|
||||
toggle(doc.id)
|
||||
}
|
||||
className={`w-full rounded-md flex items-center gap-2 pl-7 pr-2 py-2 text-xs transition-all text-left ${
|
||||
selected
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-100/70"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
|
||||
<ChevronRight className="h-3 w-3 text-gray-400 shrink-0" />
|
||||
)}
|
||||
{isExpanded ? (
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
) : (
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
)}
|
||||
<span className="flex-1 truncate font-medium text-gray-700">
|
||||
{project.name}
|
||||
{project.cm_number && (
|
||||
<span className="ml-1 font-normal text-gray-400">
|
||||
(#{project.cm_number})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 shrink-0">
|
||||
{docs.length}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{docs.length === 0 ? (
|
||||
<p className="pl-7 py-1 text-xs text-gray-400">
|
||||
Empty
|
||||
</p>
|
||||
) : (
|
||||
docs.map((doc) => {
|
||||
const selected =
|
||||
selectedIds.has(doc.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={doc.id}
|
||||
onClick={() =>
|
||||
toggle(doc.id)
|
||||
}
|
||||
className={`w-full rounded-md flex items-center gap-2 pl-7 pr-2 py-2 text-xs transition-all text-left ${
|
||||
selected
|
||||
? "bg-gray-900 border-gray-900"
|
||||
: "border-gray-300"
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-100/70"
|
||||
}`}
|
||||
>
|
||||
{selected && (
|
||||
<Check className="h-2.5 w-2.5 text-white" />
|
||||
)}
|
||||
</span>
|
||||
<DocFileIcon
|
||||
fileType={
|
||||
doc.file_type
|
||||
}
|
||||
/>
|
||||
<span
|
||||
className={`flex-1 truncate min-w-0 ${
|
||||
selected
|
||||
? "text-gray-900"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{doc.filename}
|
||||
</span>
|
||||
<VersionChip
|
||||
n={
|
||||
doc.active_version_number ??
|
||||
doc.latest_version_number
|
||||
}
|
||||
/>
|
||||
{doc.created_at && (
|
||||
<span className="shrink-0 text-gray-300">
|
||||
{formatDate(
|
||||
doc.created_at,
|
||||
<span
|
||||
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${
|
||||
selected
|
||||
? "bg-gray-900 border-gray-900"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{selected && (
|
||||
<Check className="h-2.5 w-2.5 text-white" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<DocFileIcon
|
||||
fileType={
|
||||
doc.file_type
|
||||
}
|
||||
/>
|
||||
<span
|
||||
className={`flex-1 truncate min-w-0 ${
|
||||
selected
|
||||
? "text-gray-900"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{doc.filename}
|
||||
</span>
|
||||
<VersionChip
|
||||
n={
|
||||
doc.active_version_number ??
|
||||
doc.latest_version_number
|
||||
}
|
||||
/>
|
||||
{doc.created_at && (
|
||||
<span className="shrink-0 text-gray-300">
|
||||
{formatDate(
|
||||
doc.created_at,
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{activeTab === "projects" &&
|
||||
!q &&
|
||||
visibleDirectoryProjects.length === 0 && (
|
||||
<p className="text-center text-sm text-gray-400 py-8">
|
||||
No projects yet
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileDirectoryTabs({
|
||||
activeTab,
|
||||
onChange,
|
||||
}: {
|
||||
activeTab: "files" | "projects";
|
||||
onChange: (tab: "files" | "projects") => void;
|
||||
}) {
|
||||
return (
|
||||
<ModalSegmentedToggle
|
||||
value={activeTab}
|
||||
onChange={onChange}
|
||||
options={[
|
||||
{ value: "files", label: "Files" },
|
||||
{ value: "projects", label: "Projects" },
|
||||
]}
|
||||
size="sm"
|
||||
className="self-start"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import {
|
|||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
} from "@/app/components/ui/dropdown-menu";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
|
||||
export type HeaderActionsMenuItem = {
|
||||
label: string;
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import { useAuth } from "@/app/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/app/contexts/UserProfileContext";
|
||||
import { needsMfaVerification } from "../popups/MfaVerificationPopup";
|
||||
|
||||
type GateState = "idle" | "checking" | "required" | "verified";
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
import { createPortal } from "react-dom";
|
||||
import { ChevronLeft, Loader2, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { usePageChrome } from "@/app/contexts/PageChromeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
|
||||
export interface PageHeaderBreadcrumb {
|
||||
label?: ReactNode;
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ import {
|
|||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
} from "@/app/components/ui/dropdown-menu";
|
||||
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useAuth } from "@/app/contexts/AuthContext";
|
||||
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
|
||||
import type { Chat } from "@/app/components/shared/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
|
||||
interface Props {
|
||||
chat: Chat;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
type MouseEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
import {
|
||||
CLOSE_ROW_ACTIONS_EVENT,
|
||||
closeRowActionMenus,
|
||||
|
|
|
|||
|
|
@ -590,16 +590,20 @@ export interface WorkflowContributor {
|
|||
export interface Workflow {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
title: string;
|
||||
type: "assistant" | "tabular";
|
||||
prompt_md: string | null;
|
||||
metadata: {
|
||||
title: string;
|
||||
description: string | null;
|
||||
type: "assistant" | "tabular";
|
||||
contributors: WorkflowContributor[];
|
||||
language: string;
|
||||
version: string | null;
|
||||
practice: string | null;
|
||||
jurisdictions: string[] | null;
|
||||
};
|
||||
skill_md: string | null;
|
||||
columns_config: ColumnConfig[] | null;
|
||||
is_system: boolean;
|
||||
created_at: string;
|
||||
language?: string | null;
|
||||
version?: string | null;
|
||||
practice?: string | null;
|
||||
jurisdictions?: string[] | null;
|
||||
shared_by_name?: string | null;
|
||||
allow_edit?: boolean;
|
||||
is_owner?: boolean;
|
||||
|
|
|
|||
503
frontend/src/app/components/shared/views/DocxView.tsx
Normal file
503
frontend/src/app/components/shared/views/DocxView.tsx
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useFetchDocxBytes } from "@/app/hooks/useFetchDocxBytes";
|
||||
import { supabase } from "@/app/lib/supabase";
|
||||
import {
|
||||
clearDocxQuoteHighlights,
|
||||
highlightDocxQuote,
|
||||
} from "./highlightDocxQuote";
|
||||
import type { CitationQuote } from "../types";
|
||||
|
||||
interface Props {
|
||||
documentId: string;
|
||||
versionId?: string | null;
|
||||
/**
|
||||
* Called once the document has been rendered to the DOM. Handy for
|
||||
* scrolling to a particular tracked change after a re-render.
|
||||
*/
|
||||
onReady?: () => void;
|
||||
/**
|
||||
* Tracked-change to scroll to + briefly flash after each render. The
|
||||
* `key` is used to re-trigger scrolling when the same edit is clicked
|
||||
* twice in a row.
|
||||
*/
|
||||
highlightEdit?: {
|
||||
key: string;
|
||||
inserted_text?: string;
|
||||
deleted_text?: string;
|
||||
/**
|
||||
* Numeric w:id values of the <w:ins>/<w:del> wrappers in
|
||||
* document.xml. Preferred over text matching — uniquely identifies
|
||||
* the right DOM element even when multiple edits share identical
|
||||
* inserted/deleted text. `docx-preview` drops these during parsing,
|
||||
* so we re-tag each rendered <ins>/<del> with data-w-id after load.
|
||||
*/
|
||||
ins_w_id?: string | null;
|
||||
del_w_id?: string | null;
|
||||
} | null;
|
||||
/**
|
||||
* Forces a byte re-fetch when it changes, even if documentId/versionId
|
||||
* are stable. Used after accept/reject: the backend overwrites bytes at
|
||||
* the same storage path (no new version row), so the hook has no other
|
||||
* signal that the file changed.
|
||||
*/
|
||||
refetchKey?: number;
|
||||
/**
|
||||
* Citation quotes to highlight in the rendered output. The first match
|
||||
* is scrolled into view. Page numbers are ignored — DOCX has no explicit
|
||||
* pagination the renderer can match against.
|
||||
*/
|
||||
quotes?: CitationQuote[];
|
||||
/** Changes when the parent wants the current quote re-focused. */
|
||||
quoteFocusKey?: string | number;
|
||||
/**
|
||||
* Warning banner copy rendered in the top-left of the viewer. Used
|
||||
* for non-blocking errors (e.g. "Accept failed — reverted").
|
||||
*/
|
||||
warning?: string | null;
|
||||
/**
|
||||
* Called when the user dismisses the warning banner.
|
||||
*/
|
||||
onWarningDismiss?: () => void;
|
||||
/**
|
||||
* Scroll position to restore after the first render — used by parents
|
||||
* that track per-tab scroll and want to re-enter at the same spot.
|
||||
* Null/undefined means "no override" (preserve the pre-render scroll).
|
||||
*/
|
||||
initialScrollTop?: number | null;
|
||||
/**
|
||||
* Fires on scroll (throttled by rAF) so the parent can persist the
|
||||
* current scrollTop against its tab state.
|
||||
*/
|
||||
onScrollChange?: (scrollTop: number) => void;
|
||||
rounded?: boolean;
|
||||
}
|
||||
|
||||
function findEditElement(
|
||||
root: HTMLElement,
|
||||
tag: "ins" | "del",
|
||||
opts: { w_id?: string | null; text?: string },
|
||||
): HTMLElement | null {
|
||||
if (opts.w_id) {
|
||||
const byId = root.querySelector(
|
||||
`${tag}[data-w-id="${CSS.escape(opts.w_id)}"]`,
|
||||
) as HTMLElement | null;
|
||||
if (byId) return byId;
|
||||
}
|
||||
const text = opts.text ?? "";
|
||||
const normalize = (s: string) => s.replace(/\s+/g, " ").trim();
|
||||
const target = normalize(text);
|
||||
if (!target) return null;
|
||||
const candidates = Array.from(root.querySelectorAll(tag)) as HTMLElement[];
|
||||
return (
|
||||
candidates.find((el) => normalize(el.textContent ?? "") === target) ??
|
||||
candidates.find((el) =>
|
||||
normalize(el.textContent ?? "").includes(target),
|
||||
) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function scrollToHighlight(
|
||||
container: HTMLElement,
|
||||
scrollEl: HTMLElement,
|
||||
edit: {
|
||||
inserted_text?: string;
|
||||
deleted_text?: string;
|
||||
ins_w_id?: string | null;
|
||||
del_w_id?: string | null;
|
||||
},
|
||||
) {
|
||||
const insEl = findEditElement(container, "ins", {
|
||||
w_id: edit.ins_w_id,
|
||||
text: edit.inserted_text,
|
||||
});
|
||||
const delEl = findEditElement(container, "del", {
|
||||
w_id: edit.del_w_id,
|
||||
text: edit.deleted_text,
|
||||
});
|
||||
const anchor = insEl ?? delEl;
|
||||
if (!anchor) return;
|
||||
|
||||
const scrollRect = scrollEl.getBoundingClientRect();
|
||||
const targetRect = anchor.getBoundingClientRect();
|
||||
const offset = targetRect.top - scrollRect.top + scrollEl.scrollTop - 80;
|
||||
scrollEl.scrollTo({ top: Math.max(0, offset), behavior: "smooth" });
|
||||
|
||||
const flashed = [insEl, delEl].filter((el): el is HTMLElement => !!el);
|
||||
flashed.forEach((el) => el.classList.add("docx-edit-flash"));
|
||||
window.setTimeout(() => {
|
||||
flashed.forEach((el) => el.classList.remove("docx-edit-flash"));
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the ordered list of w:ids for every w:ins/w:del in the current
|
||||
* version and tag each rendered <ins>/<del> with data-w-id. The backend
|
||||
* returns ids in document order, and docx-preview emits <ins>/<del>
|
||||
* in the same order, so we can align by index.
|
||||
*/
|
||||
async function tagWIdsOnRenderedDom(
|
||||
container: HTMLElement,
|
||||
documentId: string,
|
||||
versionId: string | null | undefined,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
const apiBase =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001";
|
||||
const qs = versionId
|
||||
? `?version_id=${encodeURIComponent(versionId)}`
|
||||
: "";
|
||||
const resp = await fetch(
|
||||
`${apiBase}/single-documents/${documentId}/tracked-change-ids${qs}`,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||
);
|
||||
if (!resp.ok) {
|
||||
console.warn(
|
||||
"[DocxView] tracked-change-ids fetch failed",
|
||||
resp.status,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as {
|
||||
ids: { kind: "ins" | "del"; w_id: string }[];
|
||||
};
|
||||
const domEls = Array.from(
|
||||
container.querySelectorAll("ins, del"),
|
||||
) as HTMLElement[];
|
||||
const ids = data.ids ?? [];
|
||||
let tagged = 0;
|
||||
let mismatched = 0;
|
||||
for (let i = 0; i < Math.min(domEls.length, ids.length); i++) {
|
||||
const el = domEls[i];
|
||||
const info = ids[i];
|
||||
if (el.tagName.toLowerCase() !== info.kind) {
|
||||
mismatched++;
|
||||
continue;
|
||||
}
|
||||
el.setAttribute("data-w-id", info.w_id);
|
||||
tagged++;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[DocxView] tagWIdsOnRenderedDom failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a .docx in the browser using `docx-preview`. Tracked changes
|
||||
* (`w:ins` / `w:del`) show up automatically with coloured strike/underline
|
||||
* styling. Scroll position is preserved across re-renders so Accept/Reject
|
||||
* doesn't jump the user back to the top.
|
||||
*/
|
||||
export function DocxView({
|
||||
documentId,
|
||||
versionId,
|
||||
onReady,
|
||||
highlightEdit,
|
||||
refetchKey,
|
||||
quotes,
|
||||
quoteFocusKey,
|
||||
warning,
|
||||
onWarningDismiss,
|
||||
initialScrollTop,
|
||||
onScrollChange,
|
||||
rounded = true,
|
||||
}: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const lastScrollTopRef = useRef(0);
|
||||
const renderKeyRef = useRef(0);
|
||||
// Ref-stabilize onReady and highlightEdit so the render effect only
|
||||
// re-fires when `bytes` actually change. Without this, any parent
|
||||
// re-render (e.g. clicking a new highlight) creates a new onReady
|
||||
// identity, triggers a full re-render, and snaps scroll back to top.
|
||||
const onReadyRef = useRef(onReady);
|
||||
onReadyRef.current = onReady;
|
||||
const highlightEditRef = useRef(highlightEdit);
|
||||
highlightEditRef.current = highlightEdit;
|
||||
const quotesRef = useRef(quotes);
|
||||
quotesRef.current = quotes;
|
||||
const initialScrollTopRef = useRef(initialScrollTop ?? null);
|
||||
initialScrollTopRef.current = initialScrollTop ?? null;
|
||||
const onScrollChangeRef = useRef(onScrollChange);
|
||||
onScrollChangeRef.current = onScrollChange;
|
||||
|
||||
// Stable key for the quote list so the re-highlight effect re-fires
|
||||
// only when the actual text/order of quotes changes.
|
||||
const quoteKey = useMemo(
|
||||
() => (quotes ?? []).map((q) => q.quote).join("||"),
|
||||
[quotes],
|
||||
);
|
||||
|
||||
const { bytes, loading, error } = useFetchDocxBytes(
|
||||
documentId,
|
||||
versionId,
|
||||
refetchKey,
|
||||
);
|
||||
|
||||
/**
|
||||
* Highlight every quote in `list` inside the rendered DOM and scroll
|
||||
* the first match into view. Returns true if any match was found.
|
||||
*/
|
||||
const applyQuoteHighlights = (
|
||||
containerEl: HTMLElement,
|
||||
scrollEl: HTMLElement,
|
||||
list: CitationQuote[] | undefined,
|
||||
): boolean => {
|
||||
clearDocxQuoteHighlights(containerEl);
|
||||
if (!list || list.length === 0) return false;
|
||||
|
||||
let firstMatch: HTMLElement | null = null;
|
||||
for (const q of list) {
|
||||
const match = highlightDocxQuote(containerEl, q.quote);
|
||||
if (match && !firstMatch) firstMatch = match;
|
||||
}
|
||||
if (!firstMatch) return false;
|
||||
|
||||
const scrollRect = scrollEl.getBoundingClientRect();
|
||||
const targetRect = firstMatch.getBoundingClientRect();
|
||||
const offset =
|
||||
targetRect.top -
|
||||
scrollRect.top +
|
||||
scrollEl.scrollTop -
|
||||
scrollEl.clientHeight / 2 +
|
||||
targetRect.height / 2;
|
||||
scrollEl.scrollTo({
|
||||
top: Math.max(0, offset),
|
||||
behavior: "instant" as ScrollBehavior,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* docx-preview renders pages at their natural Word page width (e.g.
|
||||
* ~816px for US Letter). When the side-panel is narrower than that,
|
||||
* the page overflows horizontally. Apply CSS `zoom` on each
|
||||
* section.docx so the document shrinks to fit — `zoom` (unlike
|
||||
* `transform: scale`) also shrinks the layout box, so the scroll
|
||||
* container's scrollHeight adapts. We zoom each page rather than the
|
||||
* wrapper because docx-preview injects flex styles on `.docx-wrapper`
|
||||
* that can interfere with wrapper-level zoom.
|
||||
*/
|
||||
const applyDocxScale = () => {
|
||||
const containerEl = containerRef.current;
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!containerEl || !scrollEl) return;
|
||||
const wrapper = containerEl.querySelector<HTMLElement>(".docx-wrapper");
|
||||
if (!wrapper) return;
|
||||
const sections = Array.from(
|
||||
wrapper.querySelectorAll<HTMLElement>("section.docx"),
|
||||
);
|
||||
if (sections.length === 0) return;
|
||||
// Reset zoom on every page before measuring so offsetWidth reports
|
||||
// each page's natural width (pages can have different widths — e.g.
|
||||
// mixed portrait/landscape sections).
|
||||
sections.forEach((s) => {
|
||||
s.style.zoom = "1";
|
||||
});
|
||||
// Use the scroll container's content box (clientWidth - padding)
|
||||
// as the available width.
|
||||
const styles = window.getComputedStyle(scrollEl);
|
||||
const padX =
|
||||
(parseFloat(styles.paddingLeft) || 0) +
|
||||
(parseFloat(styles.paddingRight) || 0);
|
||||
const available = scrollEl.clientWidth - padX;
|
||||
if (available <= 0) return;
|
||||
// Scale each page independently against its own natural width so
|
||||
// landscape/custom-size pages still fit without distorting the
|
||||
// page dividers.
|
||||
sections.forEach((s) => {
|
||||
const w = s.offsetWidth;
|
||||
if (!w) return;
|
||||
const scale = Math.min(1, available / w);
|
||||
s.style.zoom = String(scale);
|
||||
});
|
||||
};
|
||||
|
||||
// Observe the scroll container (which tracks the side panel's width)
|
||||
// and re-scale whenever it resizes. Also observe the docx container so
|
||||
// we re-scale once docx-preview finishes inserting pages.
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
const containerEl = containerRef.current;
|
||||
if (!scrollEl || !containerEl) return;
|
||||
let raf = 0;
|
||||
const schedule = () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(() => applyDocxScale());
|
||||
};
|
||||
const ro = new ResizeObserver(schedule);
|
||||
ro.observe(scrollEl);
|
||||
ro.observe(containerEl);
|
||||
return () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!bytes || !containerRef.current || !scrollRef.current) return;
|
||||
|
||||
const scrollEl = scrollRef.current;
|
||||
const containerEl = containerRef.current;
|
||||
|
||||
// Remember scroll position across re-renders so Accept/Reject stays put.
|
||||
lastScrollTopRef.current = scrollEl.scrollTop;
|
||||
const thisRender = ++renderKeyRef.current;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { renderAsync } = await import("docx-preview");
|
||||
if (cancelled) return;
|
||||
containerEl.innerHTML = "";
|
||||
await renderAsync(bytes, containerEl, undefined, {
|
||||
inWrapper: true,
|
||||
ignoreWidth: false,
|
||||
ignoreHeight: false,
|
||||
renderChanges: true,
|
||||
experimental: true,
|
||||
});
|
||||
if (cancelled) return;
|
||||
await tagWIdsOnRenderedDom(
|
||||
containerEl,
|
||||
documentId,
|
||||
versionId ?? null,
|
||||
);
|
||||
if (cancelled) return;
|
||||
// Scale to fit before scrolling so offsets are computed
|
||||
// against the post-zoom layout.
|
||||
applyDocxScale();
|
||||
requestAnimationFrame(() => {
|
||||
if (
|
||||
!scrollRef.current ||
|
||||
thisRender !== renderKeyRef.current
|
||||
)
|
||||
return;
|
||||
const pendingHighlight = highlightEditRef.current;
|
||||
const pendingQuotes = quotesRef.current;
|
||||
const pendingInitialScroll = initialScrollTopRef.current;
|
||||
if (pendingHighlight) {
|
||||
scrollToHighlight(
|
||||
containerEl,
|
||||
scrollRef.current,
|
||||
pendingHighlight,
|
||||
);
|
||||
// Highlight quotes too, but don't override the edit scroll
|
||||
if (pendingQuotes?.length) {
|
||||
for (const q of pendingQuotes)
|
||||
highlightDocxQuote(containerEl, q.quote);
|
||||
}
|
||||
} else if (
|
||||
pendingQuotes &&
|
||||
applyQuoteHighlights(
|
||||
containerEl,
|
||||
scrollRef.current,
|
||||
pendingQuotes,
|
||||
)
|
||||
) {
|
||||
// scrolled inside applyQuoteHighlights
|
||||
} else if (typeof pendingInitialScroll === "number") {
|
||||
scrollRef.current.scrollTop = pendingInitialScroll;
|
||||
} else {
|
||||
scrollRef.current.scrollTop = lastScrollTopRef.current;
|
||||
}
|
||||
onReadyRef.current?.();
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("docx-preview render failed", e);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [bytes]);
|
||||
|
||||
// Re-scroll/highlight if the target edit changes without a re-render
|
||||
// (e.g. same doc, different edit clicked).
|
||||
useEffect(() => {
|
||||
if (!highlightEdit || !containerRef.current || !scrollRef.current)
|
||||
return;
|
||||
scrollToHighlight(
|
||||
containerRef.current,
|
||||
scrollRef.current,
|
||||
highlightEdit,
|
||||
);
|
||||
}, [highlightEdit?.key]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Re-apply quote highlights when the quote list changes without a full
|
||||
// re-render (e.g. clicking a different citation on the same doc).
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !scrollRef.current) return;
|
||||
applyQuoteHighlights(
|
||||
containerRef.current,
|
||||
scrollRef.current,
|
||||
quotesRef.current,
|
||||
);
|
||||
}, [quoteKey, quoteFocusKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fire onScrollChange (rAF-throttled) so parents can persist scroll
|
||||
// per-tab. We still maintain lastScrollTopRef locally for same-mount
|
||||
// re-renders (Accept/Reject preserving scroll within one view).
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
let scheduled = false;
|
||||
const onScroll = () => {
|
||||
lastScrollTopRef.current = el.scrollTop;
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
scheduled = false;
|
||||
onScrollChangeRef.current?.(el.scrollTop);
|
||||
});
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-col flex-1 overflow-hidden bg-gray-100 ${rounded ? "rounded-lg" : ""}`}
|
||||
>
|
||||
{warning && (
|
||||
<div className="absolute top-2 left-2 z-10 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-800 shadow-sm">
|
||||
<span>{warning}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onWarningDismiss?.()}
|
||||
className="text-amber-600 hover:text-amber-900"
|
||||
aria-label="Dismiss warning"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-auto px-5 pt-5 pb-3 docx-view-scroll"
|
||||
data-document-id={documentId}
|
||||
data-version-id={versionId ?? ""}
|
||||
>
|
||||
{loading && !bytes && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-7 w-7 animate-spin text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div ref={containerRef} className="docx-view-container" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
598
frontend/src/app/components/shared/views/PdfView.tsx
Normal file
598
frontend/src/app/components/shared/views/PdfView.tsx
Normal file
|
|
@ -0,0 +1,598 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Loader2, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { useFetchSingleDoc } from "@/app/hooks/useFetchSingleDoc";
|
||||
import { DocxView } from "./DocxView";
|
||||
import type { CitationQuote } from "../types";
|
||||
import {
|
||||
clearHighlights,
|
||||
getPdfJs,
|
||||
highlightQuote,
|
||||
STANDARD_FONT_DATA_URL,
|
||||
} from "./highlightQuote";
|
||||
|
||||
interface Props {
|
||||
doc: { document_id: string; version_id?: string | null } | null;
|
||||
/** Preferred: one or more (page, quote) pairs to highlight. */
|
||||
quotes?: CitationQuote[];
|
||||
/** Changes when the parent wants the current quote re-focused. */
|
||||
quoteFocusKey?: string | number;
|
||||
/** Back-compat single-quote API. Ignored if `quotes` is provided. */
|
||||
quote?: string;
|
||||
fallbackPage?: number;
|
||||
rounded?: boolean;
|
||||
}
|
||||
|
||||
type QuoteEntry = { page?: number; quote: string };
|
||||
|
||||
const SIDE_PADDING = 20;
|
||||
const ZOOM_MIN = 0.5;
|
||||
const ZOOM_MAX = 3.0;
|
||||
const ZOOM_STEP = 0.25;
|
||||
|
||||
type RenderedPage = {
|
||||
page: import("pdfjs-dist").PDFPageProxy;
|
||||
viewport: import("pdfjs-dist").PageViewport;
|
||||
wrapper: HTMLDivElement;
|
||||
canvas: HTMLCanvasElement;
|
||||
textDivs: HTMLElement[];
|
||||
};
|
||||
|
||||
export function PdfView({
|
||||
doc,
|
||||
quotes,
|
||||
quoteFocusKey,
|
||||
quote,
|
||||
fallbackPage,
|
||||
rounded = true,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const pdfDocRef = useRef<import("pdfjs-dist").PDFDocumentProxy | null>(
|
||||
null,
|
||||
);
|
||||
const renderedPagesRef = useRef<RenderedPage[]>([]);
|
||||
const quoteListRef = useRef<QuoteEntry[]>([]);
|
||||
const zoomRef = useRef(1.0);
|
||||
const currentPageRef = useRef(1);
|
||||
|
||||
const quoteList: QuoteEntry[] = useMemo(() => {
|
||||
if (quotes?.length)
|
||||
return quotes.map((q) => ({ page: q.page, quote: q.quote }));
|
||||
if (quote) return [{ page: fallbackPage, quote }];
|
||||
return [];
|
||||
}, [quotes, quote, fallbackPage]);
|
||||
|
||||
// Stable string key so effects can depend on quote-list identity
|
||||
const quoteKey = quoteList
|
||||
.map((q) => `${q.page ?? ""}:${q.quote}`)
|
||||
.join("|");
|
||||
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [zoom, setZoom] = useState(1.0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
|
||||
const { result, loading, error } = useFetchSingleDoc(
|
||||
doc?.document_id ?? null,
|
||||
doc?.version_id ?? null,
|
||||
);
|
||||
|
||||
// /display returned DOCX bytes — the active version has no PDF
|
||||
// rendition, so fall back to docx-preview (still applies citation
|
||||
// highlighting via the same `quotes` API).
|
||||
const fallbackToDocx = result?.type === "docx";
|
||||
|
||||
// Track container width via ResizeObserver so re-renders fire on resize
|
||||
useEffect(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
setContainerWidth(entries[0]?.contentRect.width ?? 0);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Track current page via scroll position
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollContainerRef.current;
|
||||
if (!scrollEl) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const pages = renderedPagesRef.current;
|
||||
if (!pages.length) return;
|
||||
const scrollCenter = scrollEl.scrollTop + scrollEl.clientHeight / 2;
|
||||
let closest = 0;
|
||||
let closestDist = Infinity;
|
||||
pages.forEach((p, i) => {
|
||||
const pageCenter =
|
||||
p.wrapper.offsetTop + p.wrapper.clientHeight / 2;
|
||||
const dist = Math.abs(pageCenter - scrollCenter);
|
||||
if (dist < closestDist) {
|
||||
closestDist = dist;
|
||||
closest = i;
|
||||
}
|
||||
});
|
||||
currentPageRef.current = closest + 1;
|
||||
setCurrentPage(closest + 1);
|
||||
};
|
||||
|
||||
scrollEl.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => scrollEl.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
// Highlights all entries in `list` across the already-rendered pages.
|
||||
// Returns the 1-based page number of the first successfully highlighted entry, or null.
|
||||
const applyHighlights = useCallback(
|
||||
async (list: QuoteEntry[]): Promise<number | null> => {
|
||||
// Clear any prior highlights across all pages
|
||||
for (const p of renderedPagesRef.current)
|
||||
clearHighlights(p.textDivs);
|
||||
|
||||
let firstHitPage: number | null = null;
|
||||
for (const entry of list) {
|
||||
let hitPage: number | null = null;
|
||||
|
||||
if (entry.page) {
|
||||
const target = renderedPagesRef.current[entry.page - 1];
|
||||
if (target) {
|
||||
const found = await highlightQuote(
|
||||
target.textDivs,
|
||||
entry.quote,
|
||||
);
|
||||
if (found) hitPage = entry.page;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to scanning all pages for this quote
|
||||
if (hitPage === null) {
|
||||
console.warn(
|
||||
`Quote not found on hinted page, scanning all pages: "${entry.quote.slice(0, 60)}..."`,
|
||||
);
|
||||
for (let i = 0; i < renderedPagesRef.current.length; i++) {
|
||||
const p = renderedPagesRef.current[i];
|
||||
const found = await highlightQuote(
|
||||
p.textDivs,
|
||||
entry.quote,
|
||||
);
|
||||
if (found) {
|
||||
hitPage = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hitPage !== null && firstHitPage === null) {
|
||||
firstHitPage = hitPage;
|
||||
}
|
||||
}
|
||||
return firstHitPage;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const renderPDF = useCallback(
|
||||
async (
|
||||
doc: import("pdfjs-dist").PDFDocumentProxy,
|
||||
list: QuoteEntry[],
|
||||
scrollToPage?: number,
|
||||
) => {
|
||||
if (!containerRef.current) return;
|
||||
containerRef.current.innerHTML = "";
|
||||
renderedPagesRef.current = [];
|
||||
const lib = await getPdfJs();
|
||||
lib.TextLayer.cleanup();
|
||||
|
||||
setNumPages(doc.numPages);
|
||||
setCurrentPage(1);
|
||||
currentPageRef.current = 1;
|
||||
|
||||
const hasCitation = list.length > 0;
|
||||
if (hasCitation && scrollContainerRef.current) {
|
||||
scrollContainerRef.current.style.opacity = "0";
|
||||
}
|
||||
|
||||
const reveal = () => {
|
||||
if (scrollContainerRef.current)
|
||||
scrollContainerRef.current.style.opacity = "1";
|
||||
};
|
||||
|
||||
const panelW = containerRef.current.clientWidth;
|
||||
const firstPage = await doc.getPage(1);
|
||||
const naturalWidth = firstPage.getViewport({ scale: 1 }).width;
|
||||
const baseScale = Math.max(
|
||||
0.5,
|
||||
(panelW - SIDE_PADDING) / naturalWidth,
|
||||
);
|
||||
const scale = baseScale * zoomRef.current;
|
||||
|
||||
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
|
||||
const page = await doc.getPage(pageNum);
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.style.position = "relative";
|
||||
wrapper.style.margin = "0 auto 8px";
|
||||
wrapper.style.width = "fit-content";
|
||||
wrapper.className = "shadow-md";
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
canvas.style.display = "block";
|
||||
wrapper.appendChild(canvas);
|
||||
containerRef.current?.appendChild(wrapper);
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) continue;
|
||||
|
||||
const task = page.render({ canvasContext: ctx, viewport });
|
||||
try {
|
||||
await task.promise;
|
||||
} catch (e: unknown) {
|
||||
if (
|
||||
(e as { name?: string })?.name !==
|
||||
"RenderingCancelledException"
|
||||
) {
|
||||
console.error("PDF render error", e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const textLayerDiv = document.createElement("div");
|
||||
textLayerDiv.className = "pdf-text-layer";
|
||||
textLayerDiv.style.position = "absolute";
|
||||
textLayerDiv.style.left = "0";
|
||||
textLayerDiv.style.top = "0";
|
||||
textLayerDiv.style.width = `${viewport.width}px`;
|
||||
textLayerDiv.style.height = `${viewport.height}px`;
|
||||
textLayerDiv.style.setProperty("--scale-factor", String(scale));
|
||||
wrapper.appendChild(textLayerDiv);
|
||||
|
||||
const textLayer = new lib.TextLayer({
|
||||
textContentSource: page.streamTextContent(),
|
||||
container: textLayerDiv,
|
||||
viewport,
|
||||
});
|
||||
await textLayer.render();
|
||||
const textDivs = textLayer.textDivs;
|
||||
|
||||
renderedPagesRef.current.push({
|
||||
page,
|
||||
viewport,
|
||||
wrapper,
|
||||
canvas,
|
||||
textDivs,
|
||||
});
|
||||
}
|
||||
|
||||
// Apply highlights across all entries, then scroll to the first hit.
|
||||
let targetPage: number | null = null;
|
||||
if (list.length) {
|
||||
targetPage = await applyHighlights(list);
|
||||
if (targetPage === null) {
|
||||
// Fallback: scroll to the first entry's page hint, even without a highlight
|
||||
const hint = list.find((e) => e.page)?.page ?? null;
|
||||
targetPage = hint;
|
||||
}
|
||||
}
|
||||
if (targetPage && targetPage >= 1) {
|
||||
scrollToHighlightOnPage(targetPage);
|
||||
} else if (!hasCitation && scrollToPage && scrollToPage > 1) {
|
||||
// Restore scroll position after zoom re-render
|
||||
const pageEntry = renderedPagesRef.current[scrollToPage - 1];
|
||||
if (pageEntry)
|
||||
pageEntry.wrapper.scrollIntoView({
|
||||
behavior: "instant" as ScrollBehavior,
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
|
||||
reveal();
|
||||
},
|
||||
[applyHighlights],
|
||||
);
|
||||
|
||||
// Scroll so the first highlight on `pageNum` lands at the vertical center
|
||||
// of the viewer. We compute the scroll position explicitly on the scroll
|
||||
// container — calling `scrollIntoView` on a child of the absolutely-
|
||||
// positioned text layer can scroll just the overlay while leaving the
|
||||
// canvas untouched, which is why we don't use it here.
|
||||
function scrollToHighlightOnPage(pageNum: number) {
|
||||
const pageEntry = renderedPagesRef.current[pageNum - 1];
|
||||
const scrollEl = scrollContainerRef.current;
|
||||
if (!pageEntry || !scrollEl) return;
|
||||
|
||||
const highlightEl = pageEntry.wrapper.querySelector<HTMLElement>(
|
||||
".pdf-text-highlight",
|
||||
);
|
||||
if (highlightEl) {
|
||||
const containerRect = scrollEl.getBoundingClientRect();
|
||||
const highlightRect = highlightEl.getBoundingClientRect();
|
||||
const offsetWithinContainer = highlightRect.top - containerRect.top;
|
||||
const targetTop =
|
||||
scrollEl.scrollTop +
|
||||
offsetWithinContainer -
|
||||
scrollEl.clientHeight / 2 +
|
||||
highlightRect.height / 2;
|
||||
scrollEl.scrollTo({
|
||||
top: Math.max(0, targetTop),
|
||||
behavior: "instant" as ScrollBehavior,
|
||||
});
|
||||
} else {
|
||||
const wrapperRect = pageEntry.wrapper.getBoundingClientRect();
|
||||
const containerRect = scrollEl.getBoundingClientRect();
|
||||
const targetTop =
|
||||
scrollEl.scrollTop + (wrapperRect.top - containerRect.top);
|
||||
scrollEl.scrollTo({
|
||||
top: Math.max(0, targetTop),
|
||||
behavior: "instant" as ScrollBehavior,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rehighlightQuotes = useCallback(
|
||||
async (list: QuoteEntry[]) => {
|
||||
const targetPage = await applyHighlights(list);
|
||||
const scrollPage =
|
||||
targetPage ?? list.find((e) => e.page)?.page ?? null;
|
||||
if (scrollPage && scrollPage >= 1) {
|
||||
scrollToHighlightOnPage(scrollPage);
|
||||
}
|
||||
},
|
||||
[applyHighlights],
|
||||
);
|
||||
|
||||
// Trackpad pinch-to-zoom (wheel + ctrlKey)
|
||||
useEffect(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el) return;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (!e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
const delta = e.deltaMode === 0 ? e.deltaY / 300 : e.deltaY * 0.1;
|
||||
const next = Math.min(
|
||||
ZOOM_MAX,
|
||||
Math.max(
|
||||
ZOOM_MIN,
|
||||
Math.round(zoomRef.current * Math.exp(-delta) * 100) / 100,
|
||||
),
|
||||
);
|
||||
if (next === zoomRef.current) return;
|
||||
zoomRef.current = next;
|
||||
setZoom(next);
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
if (pdfDocRef.current) {
|
||||
renderPDF(
|
||||
pdfDocRef.current,
|
||||
quoteListRef.current,
|
||||
currentPageRef.current,
|
||||
);
|
||||
}
|
||||
}, 150);
|
||||
};
|
||||
|
||||
el.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => {
|
||||
el.removeEventListener("wheel", handleWheel);
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
};
|
||||
}, [renderPDF]);
|
||||
|
||||
// Touch pinch-to-zoom
|
||||
useEffect(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el) return;
|
||||
let initialDist = 0;
|
||||
let initialZoom = 1.0;
|
||||
|
||||
function getTouchDist(touches: TouchList) {
|
||||
const dx = touches[0].clientX - touches[1].clientX;
|
||||
const dy = touches[0].clientY - touches[1].clientY;
|
||||
return Math.hypot(dx, dy);
|
||||
}
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
if (e.touches.length === 2) {
|
||||
initialDist = getTouchDist(e.touches);
|
||||
initialZoom = zoomRef.current;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (e.touches.length !== 2 || initialDist === 0) return;
|
||||
e.preventDefault();
|
||||
const next = Math.min(
|
||||
ZOOM_MAX,
|
||||
Math.max(
|
||||
ZOOM_MIN,
|
||||
Math.round(
|
||||
initialZoom *
|
||||
(getTouchDist(e.touches) / initialDist) *
|
||||
100,
|
||||
) / 100,
|
||||
),
|
||||
);
|
||||
zoomRef.current = next;
|
||||
setZoom(next);
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e: TouchEvent) => {
|
||||
if (e.touches.length < 2 && initialDist > 0) {
|
||||
initialDist = 0;
|
||||
if (pdfDocRef.current) {
|
||||
renderPDF(
|
||||
pdfDocRef.current,
|
||||
quoteListRef.current,
|
||||
currentPageRef.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
el.addEventListener("touchstart", handleTouchStart, { passive: true });
|
||||
el.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||
el.addEventListener("touchend", handleTouchEnd, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener("touchstart", handleTouchStart);
|
||||
el.removeEventListener("touchmove", handleTouchMove);
|
||||
el.removeEventListener("touchend", handleTouchEnd);
|
||||
};
|
||||
}, [renderPDF]);
|
||||
|
||||
// Clean up PDF.js static font-measurement canvases on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
getPdfJs().then((lib) => lib.TextLayer.cleanup());
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Render PDF when fetch result arrives
|
||||
useEffect(() => {
|
||||
if (!result || result.type !== "pdf") return;
|
||||
pdfDocRef.current = null;
|
||||
renderedPagesRef.current = [];
|
||||
quoteListRef.current = quoteList;
|
||||
zoomRef.current = 1.0;
|
||||
setZoom(1.0);
|
||||
setNumPages(0);
|
||||
const list = quoteList;
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const lib = await getPdfJs();
|
||||
if (cancelled) return;
|
||||
const pdfDoc = await lib.getDocument({
|
||||
data: new Uint8Array(result.buffer),
|
||||
standardFontDataUrl: STANDARD_FONT_DATA_URL,
|
||||
}).promise;
|
||||
if (cancelled) return;
|
||||
pdfDocRef.current = pdfDoc;
|
||||
await renderPDF(pdfDoc, list);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [result, renderPDF]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Re-render at new scale when container is resized (debounced 150ms)
|
||||
useEffect(() => {
|
||||
if (!pdfDocRef.current) return;
|
||||
const timer = setTimeout(() => {
|
||||
if (pdfDocRef.current) {
|
||||
renderPDF(pdfDocRef.current, quoteListRef.current);
|
||||
}
|
||||
}, 150);
|
||||
return () => clearTimeout(timer);
|
||||
}, [containerWidth, renderPDF]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Re-highlight when quotes change without full re-render
|
||||
useEffect(() => {
|
||||
if (!pdfDocRef.current) return;
|
||||
quoteListRef.current = quoteList;
|
||||
rehighlightQuotes(quoteList);
|
||||
}, [quoteKey, quoteFocusKey, rehighlightQuotes]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function handleZoomIn() {
|
||||
const next = Math.min(
|
||||
ZOOM_MAX,
|
||||
Math.round((zoomRef.current + ZOOM_STEP) * 100) / 100,
|
||||
);
|
||||
zoomRef.current = next;
|
||||
setZoom(next);
|
||||
if (pdfDocRef.current) {
|
||||
renderPDF(
|
||||
pdfDocRef.current,
|
||||
quoteListRef.current,
|
||||
currentPageRef.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleZoomOut() {
|
||||
const next = Math.max(
|
||||
ZOOM_MIN,
|
||||
Math.round((zoomRef.current - ZOOM_STEP) * 100) / 100,
|
||||
);
|
||||
zoomRef.current = next;
|
||||
setZoom(next);
|
||||
if (pdfDocRef.current) {
|
||||
renderPDF(
|
||||
pdfDocRef.current,
|
||||
quoteListRef.current,
|
||||
currentPageRef.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackToDocx && doc?.document_id) {
|
||||
return (
|
||||
<DocxView
|
||||
documentId={doc.document_id}
|
||||
versionId={doc.version_id ?? null}
|
||||
quotes={quotes}
|
||||
quoteFocusKey={quoteFocusKey}
|
||||
rounded={rounded}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-col bg-gray-100 flex-1 overflow-hidden ${rounded ? "rounded-lg" : ""}`}
|
||||
>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-auto px-3 pt-5 pb-3"
|
||||
>
|
||||
{loading && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-7 w-7 animate-spin text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div ref={containerRef} />
|
||||
</div>
|
||||
{numPages > 0 && (
|
||||
<>
|
||||
{/* Page counter — bottom left */}
|
||||
<div className="absolute bottom-4 left-4 pointer-events-none">
|
||||
<span className="flex items-center px-3 py-1.5 rounded-full text-xs font-medium tabular-nums text-gray-700 bg-white/25 backdrop-blur-md border border-white/30 shadow-md">
|
||||
{currentPage}/{numPages}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Zoom controls — bottom right */}
|
||||
<div className="absolute bottom-4 right-4 flex items-center gap-px rounded-full bg-white/25 backdrop-blur-md border border-white/30 shadow-md px-1 py-1">
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoom <= ZOOM_MIN}
|
||||
className="flex items-center justify-center w-7 h-7 rounded-full text-gray-600 hover:bg-white/80 disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<ZoomOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span className="text-xs font-medium text-gray-600 tabular-nums w-9 text-center select-none">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
disabled={zoom >= ZOOM_MAX}
|
||||
className="flex items-center justify-center w-7 h-7 rounded-full text-gray-600 hover:bg-white/80 disabled:opacity-30 transition-colors"
|
||||
>
|
||||
<ZoomIn className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
518
frontend/src/app/components/shared/views/SpreadsheetView.tsx
Normal file
518
frontend/src/app/components/shared/views/SpreadsheetView.tsx
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import LuckyExcel, { type LuckyExcelSheet } from "luckyexcel";
|
||||
import type { WorkbookInstance } from "@fortune-sheet/react";
|
||||
import type { Cell, Sheet } from "@fortune-sheet/core";
|
||||
import "@fortune-sheet/react/dist/index.css";
|
||||
import { useFetchSingleDoc } from "@/app/hooks/useFetchSingleDoc";
|
||||
|
||||
type HighlightRange = { row: [number, number]; column: [number, number] };
|
||||
type WorkbookComponent = typeof import("@fortune-sheet/react").Workbook;
|
||||
|
||||
/** A cell locator to highlight, e.g. { sheet: "Q3 Budget", cell: "B7" }. */
|
||||
export type HighlightCell = { sheet?: string; cell?: string };
|
||||
|
||||
interface Props {
|
||||
documentId: string;
|
||||
versionId?: string | null;
|
||||
/** Cell(s) to select/scroll to (from a spreadsheet citation). */
|
||||
highlightCells?: HighlightCell[];
|
||||
rounded?: boolean;
|
||||
}
|
||||
|
||||
/** "B" -> 1, "AA" -> 26 (0-based column index). */
|
||||
function columnLettersToIndex(letters: string): number {
|
||||
let n = 0;
|
||||
for (const ch of letters.toUpperCase()) {
|
||||
n = n * 26 + (ch.charCodeAt(0) - 64);
|
||||
}
|
||||
return n - 1;
|
||||
}
|
||||
|
||||
/** Parse an A1 address like "B7" into 0-based { r, c }. */
|
||||
function parseA1(cell: string): { r: number; c: number } | null {
|
||||
const m = cell.trim().match(/^([A-Za-z]+)(\d+)$/);
|
||||
if (!m) return null;
|
||||
const c = columnLettersToIndex(m[1]);
|
||||
const r = Number.parseInt(m[2], 10) - 1;
|
||||
if (c < 0 || r < 0) return null;
|
||||
return { r, c };
|
||||
}
|
||||
|
||||
/** Parse an A1 address or range ("B7" or "B7:C9") into 0-based row/col spans. */
|
||||
function parseRange(range: string): HighlightRange | null {
|
||||
const [startRaw, endRaw] = range.split(":");
|
||||
const start = parseA1(startRaw);
|
||||
if (!start) return null;
|
||||
const end = endRaw ? parseA1(endRaw) : start;
|
||||
if (!end) return null;
|
||||
return {
|
||||
row: [Math.min(start.r, end.r), Math.max(start.r, end.r)],
|
||||
column: [Math.min(start.c, end.c), Math.max(start.c, end.c)],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a highlight range to cover any merged ranges it intersects. Fortune-
|
||||
* sheet paints a merge as one block anchored at its top-left cell and never
|
||||
* paints the covered cells, so a citation to a covered cell (e.g. B1 inside
|
||||
* A1:C1) would otherwise highlight nothing. The model is asked to cite the full
|
||||
* merged range, but this is a deterministic fallback for when it cites a covered
|
||||
* cell anyway. Expanding to the anchor makes `afterRenderCell` paint the block.
|
||||
*/
|
||||
function expandRangeForMerges(
|
||||
sheet: Sheet,
|
||||
range: HighlightRange,
|
||||
): HighlightRange {
|
||||
const merges = (sheet.config as { merge?: Record<string, MergeInfo> })
|
||||
?.merge;
|
||||
if (!merges) return range;
|
||||
let [r0, r1] = range.row;
|
||||
let [c0, c1] = range.column;
|
||||
for (const m of Object.values(merges)) {
|
||||
const mr1 = m.r + m.rs - 1;
|
||||
const mc1 = m.c + m.cs - 1;
|
||||
if (r0 <= mr1 && r1 >= m.r && c0 <= mc1 && c1 >= m.c) {
|
||||
r0 = Math.min(r0, m.r);
|
||||
r1 = Math.max(r1, mr1);
|
||||
c0 = Math.min(c0, m.c);
|
||||
c1 = Math.max(c1, mc1);
|
||||
}
|
||||
}
|
||||
return { row: [r0, r1], column: [c0, c1] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pixel offset (from the grid origin) and size of a row/col span, derived from
|
||||
* the sheet's column widths / row heights. Fortune-sheet defaults are 73px wide
|
||||
* and 19px tall; per-index overrides live in `config.columnlen`/`config.rowlen`.
|
||||
* Hidden rows/cols aren't accounted for, so this is best-effort (enough to
|
||||
* decide whether the cell is on screen and where to center it).
|
||||
*/
|
||||
function rangePixelRect(
|
||||
sheet: Sheet,
|
||||
range: HighlightRange,
|
||||
): { x: number; y: number; w: number; h: number } {
|
||||
const cfg = (sheet.config ?? {}) as {
|
||||
columnlen?: Record<string, number>;
|
||||
rowlen?: Record<string, number>;
|
||||
};
|
||||
const colLen = cfg.columnlen ?? {};
|
||||
const rowLen = cfg.rowlen ?? {};
|
||||
const colW = (c: number) => colLen[c] ?? sheet.defaultColWidth ?? 73;
|
||||
const rowH = (r: number) => rowLen[r] ?? sheet.defaultRowHeight ?? 19;
|
||||
|
||||
let x = 0;
|
||||
for (let c = 0; c < range.column[0]; c++) x += colW(c);
|
||||
let w = 0;
|
||||
for (let c = range.column[0]; c <= range.column[1]; c++) w += colW(c);
|
||||
let y = 0;
|
||||
for (let r = 0; r < range.row[0]; r++) y += rowH(r);
|
||||
let h = 0;
|
||||
for (let r = range.row[0]; r <= range.row[1]; r++) h += rowH(r);
|
||||
return { x, y, w, h };
|
||||
}
|
||||
|
||||
type MergeInfo = { r: number; c: number; rs: number; cs: number };
|
||||
type CellData = { r: number; c: number; v: Record<string, unknown> };
|
||||
|
||||
/**
|
||||
* Expand `config.merge` onto the cells. Luckyexcel records merges in
|
||||
* `config.merge` but Fortune-sheet only renders a merge when the cells carry
|
||||
* `mc` (the anchor gets `{r,c,rs,cs}`; every covered cell points back with
|
||||
* `{r,c}`). Without this, merged ranges render as separate single cells.
|
||||
*/
|
||||
function applyMergeCells(sheets: LuckyExcelSheet[]): void {
|
||||
for (const sheet of sheets) {
|
||||
const merges = (sheet.config as { merge?: Record<string, MergeInfo> })
|
||||
?.merge;
|
||||
if (!merges) continue;
|
||||
|
||||
if (!Array.isArray(sheet.celldata)) sheet.celldata = [];
|
||||
const celldata = sheet.celldata as CellData[];
|
||||
|
||||
const byKey = new Map<string, CellData>();
|
||||
for (const entry of celldata) {
|
||||
if (typeof entry?.r === "number" && typeof entry?.c === "number") {
|
||||
byKey.set(`${entry.r}_${entry.c}`, entry);
|
||||
}
|
||||
}
|
||||
const ensureCell = (r: number, c: number): CellData => {
|
||||
const key = `${r}_${c}`;
|
||||
let entry = byKey.get(key);
|
||||
if (!entry) {
|
||||
entry = { r, c, v: {} };
|
||||
celldata.push(entry);
|
||||
byKey.set(key, entry);
|
||||
}
|
||||
if (!entry.v || typeof entry.v !== "object") entry.v = {};
|
||||
return entry;
|
||||
};
|
||||
|
||||
for (const mc of Object.values(merges)) {
|
||||
ensureCell(mc.r, mc.c).v.mc = {
|
||||
r: mc.r,
|
||||
c: mc.c,
|
||||
rs: mc.rs,
|
||||
cs: mc.cs,
|
||||
};
|
||||
for (let rr = mc.r; rr < mc.r + mc.rs; rr++) {
|
||||
for (let cc = mc.c; cc < mc.c + mc.cs; cc++) {
|
||||
if (rr === mc.r && cc === mc.c) continue;
|
||||
ensureCell(rr, cc).v.mc = { r: mc.r, c: mc.c };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make text cells overflow into empty adjacent cells, mirroring Excel's default.
|
||||
* Fortune-sheet only spills a cell's text when its `tb` (text-break) is "1";
|
||||
* Luckyexcel leaves text cells clipping, so we set `tb: "1"` on unwrapped,
|
||||
* non-merged text cells. Fortune-sheet still only paints the overflow over
|
||||
* genuinely empty neighbors, so this matches Excel (numbers/dates keep the
|
||||
* default and are not spilled).
|
||||
*/
|
||||
function applyExcelTextOverflow(sheets: LuckyExcelSheet[]): void {
|
||||
for (const sheet of sheets) {
|
||||
const celldata = sheet.celldata;
|
||||
if (!Array.isArray(celldata)) continue;
|
||||
for (const entry of celldata) {
|
||||
const cell = (entry as { v?: Record<string, unknown> } | null)?.v;
|
||||
if (!cell || typeof cell !== "object") continue;
|
||||
if (cell.mc) continue; // part of a merge - leave as-is
|
||||
if (cell.tb === "2") continue; // explicit wrap-text - keep
|
||||
if (typeof cell.v === "string" && cell.v.length > 0) {
|
||||
cell.tb = "1"; // text: overflow into empty neighbors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tint a row/column header cell gray via Fortune-sheet's own header render
|
||||
* hooks. Fortune-sheet paints the header labels (A/B/C, 1/2/3) onto the grid
|
||||
* canvas with a white fill and dark text; we lay a translucent gray over that
|
||||
* cell in the `afterRender…HeaderCell` pass. A low alpha darkens the white
|
||||
* background into gray while leaving the dark labels legible — unlike an opaque
|
||||
* CSS background on the header overlay divs, which sits in front of the canvas
|
||||
* and hides the labels entirely.
|
||||
*/
|
||||
const HEADER_TINT = "rgba(148, 163, 184, 0.18)";
|
||||
|
||||
function tintHeaderCell(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
): void {
|
||||
ctx.save();
|
||||
ctx.fillStyle = HEADER_TINT;
|
||||
ctx.fillRect(x, y, width, height);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an Excel workbook as a read-only grid using Fortune-sheet. It fetches
|
||||
* the document's raw `.xlsx`/`.xlsm`/`.xls` bytes itself (via /display) and
|
||||
* converts them to Fortune-sheet data with Luckyexcel, preserving the original
|
||||
* styling (fills, fonts, borders, merges).
|
||||
*
|
||||
* Spreadsheet citations are highlighted by cell: `highlightCells` activates the
|
||||
* cited sheet tab and scrolls the A1 address/range into view, where a canvas
|
||||
* hook paints the highlight.
|
||||
*/
|
||||
export function SpreadsheetView({
|
||||
documentId,
|
||||
versionId,
|
||||
highlightCells,
|
||||
rounded = true,
|
||||
}: Props) {
|
||||
const workbookRef = useRef<WorkbookInstance>(null);
|
||||
// The frame element, used to reach Fortune-sheet's scrollbars for measuring
|
||||
// the current scroll offset and viewport size when deciding whether to scroll.
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Current highlight, read by the render hook. A ref (not state) so updating
|
||||
// it never re-mounts the Workbook or changes the settings object.
|
||||
const highlightRef = useRef<HighlightRange | null>(null);
|
||||
const [sheets, setSheets] = useState<Sheet[] | null>(null);
|
||||
const [WorkbookComponent, setWorkbookComponent] =
|
||||
useState<WorkbookComponent | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch the raw workbook bytes. For spreadsheets, /display returns the
|
||||
// original .xlsx/.xlsm/.xls bytes rather than a PDF rendition.
|
||||
const { result, error: fetchError } = useFetchSingleDoc(
|
||||
documentId,
|
||||
versionId,
|
||||
);
|
||||
|
||||
// Fortune-sheet touches browser-only APIs while loading, so keep the import
|
||||
// inside the client component even though this file also owns the view.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
import("@fortune-sheet/react").then((mod) => {
|
||||
if (!cancelled) setWorkbookComponent(() => mod.Workbook);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Stable key so the highlight effect only re-runs when the target changes.
|
||||
const highlightKey = useMemo(
|
||||
() =>
|
||||
(highlightCells ?? [])
|
||||
.map((h) => `${h.sheet ?? ""}!${h.cell ?? ""}`)
|
||||
.join("|"),
|
||||
[highlightCells],
|
||||
);
|
||||
|
||||
// Parse the workbook with Luckyexcel, which converts the .xlsx to
|
||||
// Fortune-sheet data while preserving styling (fills, fonts, borders,
|
||||
// alignment, column widths).
|
||||
useEffect(() => {
|
||||
if (!result) return;
|
||||
if (result.type !== "spreadsheet") {
|
||||
setError("This spreadsheet could not be displayed.");
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setSheets(null);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const file = new File([result.buffer], "spreadsheet.xlsx");
|
||||
LuckyExcel.transformExcelToLucky(file, (exportJson) => {
|
||||
if (cancelled) return;
|
||||
if (exportJson?.sheets?.length) {
|
||||
applyMergeCells(exportJson.sheets);
|
||||
applyExcelTextOverflow(exportJson.sheets);
|
||||
setSheets(exportJson.sheets as unknown as Sheet[]);
|
||||
} else {
|
||||
setError("This spreadsheet could not be displayed.");
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
if (!cancelled)
|
||||
setError("This spreadsheet could not be displayed.");
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [result]);
|
||||
|
||||
// Draw the citation highlight on the canvas. Stable identity so the Workbook
|
||||
// settings never change; it reads the live target from `highlightRef`. We use
|
||||
// this instead of `setSelection`, whose in-place mutation of the range object
|
||||
// crashes under React Strict Mode's double-invoked immer producer.
|
||||
const afterRenderCell = useCallback(
|
||||
(
|
||||
_cell: Cell | null,
|
||||
info: {
|
||||
row: number;
|
||||
column: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
},
|
||||
ctx: CanvasRenderingContext2D,
|
||||
) => {
|
||||
const range = highlightRef.current;
|
||||
if (!range) return;
|
||||
if (
|
||||
info.row < range.row[0] ||
|
||||
info.row > range.row[1] ||
|
||||
info.column < range.column[0] ||
|
||||
info.column > range.column[1]
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const w = info.endX - info.startX;
|
||||
const h = info.endY - info.startY;
|
||||
ctx.save();
|
||||
ctx.fillStyle = "rgba(59, 130, 246, 0.16)";
|
||||
ctx.fillRect(info.startX, info.startY, w, h);
|
||||
ctx.strokeStyle = "#3b82f6";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(info.startX + 1, info.startY + 1, w - 2, h - 2);
|
||||
ctx.restore();
|
||||
},
|
||||
[],
|
||||
);
|
||||
const hooks = useMemo(
|
||||
() => ({
|
||||
afterRenderCell,
|
||||
// Tint the header strips gray while keeping the A/B/C, 1/2/3 labels
|
||||
// visible (see tintHeaderCell). Column cells fill from y=0; row cells
|
||||
// fill from x=0 — matching Fortune-sheet's own default header rects.
|
||||
afterRenderColumnHeaderCell: (
|
||||
_char: string,
|
||||
_idx: number,
|
||||
left: number,
|
||||
width: number,
|
||||
height: number,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
) => tintHeaderCell(left, 0, width, height, ctx),
|
||||
afterRenderRowHeaderCell: (
|
||||
_num: string,
|
||||
_idx: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
) => tintHeaderCell(0, top, width, height, ctx),
|
||||
}),
|
||||
[afterRenderCell],
|
||||
);
|
||||
|
||||
// Activate the cited sheet, bring the cell into view, and repaint the
|
||||
// highlight. We only scroll when the cell is off screen (centering it);
|
||||
// when it's already visible we leave the viewport put and force a redraw so
|
||||
// the `afterRenderCell` hook repaints the new highlight (and clears the old
|
||||
// one). Both paths must trigger a redraw: `scroll()` repaints because the
|
||||
// position changes, and the synthetic "resize" repaints in place via
|
||||
// Fortune-sheet's window resize handler.
|
||||
useEffect(() => {
|
||||
if (!sheets) return;
|
||||
const target = highlightCells?.[0];
|
||||
|
||||
const sheetIndex = target?.sheet
|
||||
? Math.max(
|
||||
0,
|
||||
sheets.findIndex((s) => s.name === target.sheet),
|
||||
)
|
||||
: 0;
|
||||
|
||||
const parsed = target?.cell ? parseRange(target.cell) : null;
|
||||
const range = parsed
|
||||
? expandRangeForMerges(sheets[sheetIndex], parsed)
|
||||
: null;
|
||||
highlightRef.current = range;
|
||||
if (!range && !target?.sheet) return;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
const inst = workbookRef.current;
|
||||
if (!inst) return;
|
||||
try {
|
||||
inst.activateSheet({ index: sheetIndex });
|
||||
if (!range) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const sbX = container?.querySelector<HTMLElement>(
|
||||
".luckysheet-scrollbar-x",
|
||||
);
|
||||
const sbY = container?.querySelector<HTMLElement>(
|
||||
".luckysheet-scrollbar-y",
|
||||
);
|
||||
|
||||
// Without the scrollbars we can't measure the viewport; fall back to
|
||||
// corner-scrolling, which at least brings the cell in and repaints.
|
||||
if (!sbX || !sbY) {
|
||||
inst.scroll({
|
||||
targetRow: range.row[0],
|
||||
targetColumn: range.column[0],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = rangePixelRect(sheets[sheetIndex], range);
|
||||
const curLeft = sbX.scrollLeft;
|
||||
const curTop = sbY.scrollTop;
|
||||
const viewW = sbX.clientWidth;
|
||||
const viewH = sbY.clientHeight;
|
||||
const visible =
|
||||
rect.x >= curLeft &&
|
||||
rect.x + rect.w <= curLeft + viewW &&
|
||||
rect.y >= curTop &&
|
||||
rect.y + rect.h <= curTop + viewH;
|
||||
|
||||
if (visible) {
|
||||
// On screen: keep the viewport still, just repaint the highlight.
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
} else {
|
||||
// Off screen: center the cell. scroll() re-renders and repaints.
|
||||
inst.scroll({
|
||||
scrollLeft: Math.max(
|
||||
0,
|
||||
Math.round(rect.x - (viewW - rect.w) / 2),
|
||||
),
|
||||
scrollTop: Math.max(
|
||||
0,
|
||||
Math.round(rect.y - (viewH - rect.h) / 2),
|
||||
),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* highlighting is best-effort */
|
||||
}
|
||||
}, 200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [sheets, highlightCells, highlightKey]);
|
||||
|
||||
const frameClass = `fortune-sheet-viewer relative flex flex-col flex-1 min-h-0 overflow-hidden ${rounded ? "rounded-lg" : ""}`;
|
||||
|
||||
const message =
|
||||
error ?? (fetchError ? "Failed to load spreadsheet." : null);
|
||||
if (message) {
|
||||
return (
|
||||
<div className={frameClass}>
|
||||
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-gray-500">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!sheets || !WorkbookComponent) {
|
||||
return (
|
||||
<div className={frameClass}>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-7 w-7 animate-spin text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={frameClass}>
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<WorkbookComponent
|
||||
ref={workbookRef}
|
||||
data={sheets}
|
||||
hooks={hooks}
|
||||
allowEdit={false}
|
||||
showToolbar={false}
|
||||
showFormulaBar={false}
|
||||
/>
|
||||
<style jsx global>{`
|
||||
/* The row/col header strips are transparent overlays over
|
||||
the label canvas — leave them so the labels show. Only the
|
||||
corner (no label) gets a matching gray background here; the
|
||||
header strips are tinted on the canvas via render hooks. */
|
||||
.fortune-sheet-viewer .fortune-left-top {
|
||||
background-color: #eceef2;
|
||||
}
|
||||
|
||||
.fortune-sheet-viewer .fortune-row-header-hover,
|
||||
.fortune-sheet-viewer .fortune-col-header-hover {
|
||||
background-color: rgba(209, 213, 219, 0.65);
|
||||
}
|
||||
|
||||
.fortune-sheet-viewer .fortune-row-header-selected,
|
||||
.fortune-sheet-viewer .fortune-col-header-selected {
|
||||
background-color: rgba(156, 163, 175, 0.28);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpreadsheetView;
|
||||
130
frontend/src/app/components/shared/views/highlightDocxQuote.ts
Normal file
130
frontend/src/app/components/shared/views/highlightDocxQuote.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
const HIGHLIGHT_CLASS = "docx-text-highlight";
|
||||
const IGNORED_TEXT_SELECTOR = ".star-pagination,.case-page-number";
|
||||
|
||||
function onlyLetters(s: string): string {
|
||||
return s.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
function toOrigPos(text: string, strippedPos: number): number {
|
||||
let count = 0;
|
||||
for (let k = 0; k < text.length; k++) {
|
||||
if (/[a-zA-Z0-9]/.test(text[k])) {
|
||||
if (count === strippedPos) return k;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return text.length;
|
||||
}
|
||||
|
||||
function collectTextNodes(root: HTMLElement): Text[] {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node: Node) {
|
||||
const p = node.parentElement;
|
||||
if (!p) return NodeFilter.FILTER_REJECT;
|
||||
const tag = p.tagName;
|
||||
if (tag === "STYLE" || tag === "SCRIPT")
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
if (p.closest(IGNORED_TEXT_SELECTOR))
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
},
|
||||
});
|
||||
const out: Text[] = [];
|
||||
let cur = walker.nextNode() as Text | null;
|
||||
while (cur) {
|
||||
out.push(cur);
|
||||
cur = walker.nextNode() as Text | null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function clearDocxQuoteHighlights(root: HTMLElement): void {
|
||||
root.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach((span) => {
|
||||
const parent = span.parentNode;
|
||||
if (!parent) return;
|
||||
while (span.firstChild) parent.insertBefore(span.firstChild, span);
|
||||
parent.removeChild(span);
|
||||
});
|
||||
root.normalize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight the given quote text inside `root` (a docx-preview output).
|
||||
* Quote is split on ellipsis variants; each segment is located via
|
||||
* letters-only substring matching, so whitespace/punctuation differences
|
||||
* between the LLM's quote and the rendered text don't break matching.
|
||||
*
|
||||
* Returns the first highlight span if any match was found, for
|
||||
* scroll-into-view by the caller.
|
||||
*/
|
||||
export function highlightDocxQuote(
|
||||
root: HTMLElement,
|
||||
quote: string,
|
||||
): HTMLElement | null {
|
||||
clearDocxQuoteHighlights(root);
|
||||
if (!quote) return null;
|
||||
const segments = quote
|
||||
.split(/\.{3}|…/)
|
||||
.map(onlyLetters)
|
||||
.filter((s) => s.length > 0);
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
const textNodes = collectTextNodes(root);
|
||||
const nodeStartInFull: number[] = [];
|
||||
const nodeStrippedLen: number[] = [];
|
||||
let fullStripped = "";
|
||||
for (const node of textNodes) {
|
||||
const stripped = onlyLetters(node.data);
|
||||
nodeStartInFull.push(fullStripped.length);
|
||||
nodeStrippedLen.push(stripped.length);
|
||||
fullStripped += stripped;
|
||||
}
|
||||
|
||||
type Range = { nodeIdx: number; origStart: number; origEnd: number };
|
||||
const ranges: Range[] = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
const searchKey = segment.slice(0, 30);
|
||||
const matchPos = fullStripped.indexOf(searchKey);
|
||||
if (matchPos < 0) continue;
|
||||
const matchEnd = matchPos + segment.length;
|
||||
|
||||
for (let i = 0; i < textNodes.length; i++) {
|
||||
const start = nodeStartInFull[i];
|
||||
const end = start + nodeStrippedLen[i];
|
||||
if (matchPos >= end || matchEnd <= start) continue;
|
||||
|
||||
const localStart = Math.max(0, matchPos - start);
|
||||
const localEnd = Math.min(nodeStrippedLen[i], matchEnd - start);
|
||||
const text = textNodes[i].data;
|
||||
const origStart = toOrigPos(text, localStart);
|
||||
const origEnd = toOrigPos(text, localEnd);
|
||||
if (origStart >= origEnd) continue;
|
||||
ranges.push({ nodeIdx: i, origStart, origEnd });
|
||||
}
|
||||
}
|
||||
|
||||
if (ranges.length === 0) return null;
|
||||
|
||||
// Apply in reverse document order so splits don't shift earlier ranges.
|
||||
ranges.sort((a, b) => {
|
||||
if (a.nodeIdx !== b.nodeIdx) return b.nodeIdx - a.nodeIdx;
|
||||
return b.origStart - a.origStart;
|
||||
});
|
||||
|
||||
const spans: HTMLElement[] = [];
|
||||
for (const r of ranges) {
|
||||
const node = textNodes[r.nodeIdx];
|
||||
const mid = node.splitText(r.origStart);
|
||||
mid.splitText(r.origEnd - r.origStart);
|
||||
const span = document.createElement("span");
|
||||
span.className = HIGHLIGHT_CLASS;
|
||||
mid.parentNode?.insertBefore(span, mid);
|
||||
span.appendChild(mid);
|
||||
spans.push(span);
|
||||
}
|
||||
|
||||
// Because we processed ranges in reverse order, the earliest-in-document
|
||||
// highlight is the last one we pushed.
|
||||
return spans[spans.length - 1] ?? null;
|
||||
}
|
||||
124
frontend/src/app/components/shared/views/highlightQuote.ts
Normal file
124
frontend/src/app/components/shared/views/highlightQuote.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
let pdfjsLib: typeof import("pdfjs-dist") | null = null;
|
||||
|
||||
export async function getPdfJs() {
|
||||
if (pdfjsLib) return pdfjsLib;
|
||||
pdfjsLib = await import("pdfjs-dist");
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||
import.meta.url,
|
||||
).toString();
|
||||
return pdfjsLib;
|
||||
}
|
||||
|
||||
export const STANDARD_FONT_DATA_URL =
|
||||
"https://unpkg.com/pdfjs-dist@4.10.38/standard_fonts/";
|
||||
|
||||
const HIGHLIGHT_CLASS = "pdf-text-highlight";
|
||||
const ORIGINAL_TEXT_ATTR = "data-original-text";
|
||||
|
||||
// Strip everything except alphanumerics (a-z A-Z 0-9) for robust matching
|
||||
function onlyLetters(s: string): string {
|
||||
return s.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// Given a position in the stripped (letters-only) version of `original`,
|
||||
// return the corresponding index in `original`.
|
||||
function strippedPosToOriginal(original: string, strippedPos: number): number {
|
||||
let count = 0;
|
||||
for (let i = 0; i < original.length; i++) {
|
||||
if (/[a-zA-Z0-9]/.test(original[i])) {
|
||||
if (count === strippedPos) return i;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return original.length;
|
||||
}
|
||||
|
||||
export function clearHighlights(textDivs: HTMLElement[]) {
|
||||
for (const div of textDivs) {
|
||||
if (div.hasAttribute(ORIGINAL_TEXT_ATTR)) {
|
||||
div.textContent = div.getAttribute(ORIGINAL_TEXT_ATTR)!;
|
||||
div.removeAttribute(ORIGINAL_TEXT_ATTR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function highlightQuote(
|
||||
textDivs: HTMLElement[],
|
||||
quote: string,
|
||||
): Promise<boolean> {
|
||||
clearHighlights(textDivs);
|
||||
|
||||
// Split on ellipsis variants to highlight each segment separately
|
||||
const segments = quote
|
||||
.split(/\.{3}|…/)
|
||||
.map((s) => onlyLetters(s))
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
// Build the stripped full text and track each div's start position within it.
|
||||
// Also keep original div texts for display.
|
||||
const divOrigTexts: string[] = []; // original text for innerHTML slicing
|
||||
const divStripped: string[] = []; // letters-only version for matching
|
||||
const divStartInFull: number[] = []; // start index in fullStripped
|
||||
let fullStripped = "";
|
||||
|
||||
for (let i = 0; i < textDivs.length; i++) {
|
||||
const orig = textDivs[i].textContent ?? "";
|
||||
divOrigTexts.push(orig);
|
||||
const stripped = onlyLetters(orig);
|
||||
divStripped.push(stripped);
|
||||
divStartInFull.push(fullStripped.length);
|
||||
fullStripped += stripped;
|
||||
}
|
||||
|
||||
// Map: divIndex -> [strippedLocalStart, strippedLocalEnd]
|
||||
const divHighlightRanges = new Map<number, [number, number]>();
|
||||
|
||||
for (const segment of segments) {
|
||||
const searchKey = segment.slice(0, 30);
|
||||
const matchPos = fullStripped.indexOf(searchKey);
|
||||
if (matchPos === -1) {
|
||||
continue;
|
||||
}
|
||||
const matchEnd = matchPos + segment.length;
|
||||
|
||||
for (let i = 0; i < textDivs.length; i++) {
|
||||
const divStart = divStartInFull[i];
|
||||
const divEnd = divStart + divStripped[i].length;
|
||||
if (matchPos >= divEnd || matchEnd <= divStart) continue;
|
||||
|
||||
const localStart = Math.max(0, matchPos - divStart);
|
||||
const localEnd = Math.min(
|
||||
divStripped[i].length,
|
||||
matchEnd - divStart,
|
||||
);
|
||||
divHighlightRanges.set(i, [localStart, localEnd]);
|
||||
}
|
||||
}
|
||||
|
||||
if (divHighlightRanges.size === 0) return false;
|
||||
|
||||
for (const [idx, [strStart, strEnd]] of divHighlightRanges) {
|
||||
const div = textDivs[idx];
|
||||
const orig = divOrigTexts[idx];
|
||||
|
||||
// Map stripped positions back to original character positions
|
||||
const origStart = strippedPosToOriginal(orig, strStart);
|
||||
const origEnd = strippedPosToOriginal(orig, strEnd);
|
||||
|
||||
div.setAttribute(ORIGINAL_TEXT_ATTR, orig);
|
||||
div.innerHTML =
|
||||
escapeHtml(orig.slice(0, origStart)) +
|
||||
`<span class="${HIGHLIGHT_CLASS}">${escapeHtml(orig.slice(origStart, origEnd))}</span>` +
|
||||
escapeHtml(orig.slice(origEnd));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import Link from "next/link";
|
||||
import { MikeIcon } from "@/components/chat/mike-icon";
|
||||
import { MikeIcon } from "@/app/components/chat/mike-icon";
|
||||
|
||||
interface SiteLogoProps {
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
|
|
@ -1,21 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ChevronDown, Plus, X } from "lucide-react";
|
||||
import type { ColumnConfig, ColumnFormat } from "../shared/types";
|
||||
import { generateTabularColumnPrompt } from "@/app/lib/mikeApi";
|
||||
import { FORMAT_OPTIONS, formatLabel, formatIcon } from "./columnFormat";
|
||||
import { FORMAT_OPTIONS } from "./columnFormat";
|
||||
import { TAG_COLORS } from "./pillUtils";
|
||||
import { getPresetConfig, PROMPT_PRESETS } from "./columnPresets";
|
||||
import { Modal } from "../modals/Modal";
|
||||
import { ModalFieldLabel } from "../modals/ModalFieldLabel";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ModalSelect } from "../modals/ModalSelect";
|
||||
import { ModalTextarea } from "../modals/ModalTextarea";
|
||||
import { ModalTextInput } from "../modals/ModalTextInput";
|
||||
|
||||
interface ColumnDraft {
|
||||
name: string;
|
||||
|
|
@ -45,7 +41,9 @@ interface Props {
|
|||
|
||||
export function AddColumnModal({ open, existingCount, onClose, onAdd, editingColumn, onSave, onDelete }: Props) {
|
||||
const isEditing = !!editingColumn;
|
||||
const formId = "add-column-modal-form";
|
||||
const [columns, setColumns] = useState<ColumnDraft[]>([{ ...EMPTY_DRAFT }]);
|
||||
const [collapsedIndices, setCollapsedIndices] = useState<number[]>([]);
|
||||
const [generatingIndices, setGeneratingIndices] = useState<number[]>([]);
|
||||
const [presetsOpenIndex, setPresetsOpenIndex] = useState<number | null>(
|
||||
null,
|
||||
|
|
@ -65,6 +63,7 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
|
|||
} else {
|
||||
setColumns([{ ...EMPTY_DRAFT }]);
|
||||
}
|
||||
setCollapsedIndices([]);
|
||||
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -86,6 +85,7 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
|
|||
|
||||
function resetForm() {
|
||||
setColumns([{ ...EMPTY_DRAFT }]);
|
||||
setCollapsedIndices([]);
|
||||
setGeneratingIndices([]);
|
||||
}
|
||||
|
||||
|
|
@ -110,6 +110,24 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
|
|||
? [{ ...EMPTY_DRAFT }]
|
||||
: prev.filter((_, i) => i !== index),
|
||||
);
|
||||
setCollapsedIndices((prev) =>
|
||||
prev
|
||||
.filter((collapsedIndex) => collapsedIndex !== index)
|
||||
.map((collapsedIndex) =>
|
||||
collapsedIndex > index
|
||||
? collapsedIndex - 1
|
||||
: collapsedIndex,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function toggleColumnCollapsed(index: number) {
|
||||
setCollapsedIndices((prev) =>
|
||||
prev.includes(index)
|
||||
? prev.filter((collapsedIndex) => collapsedIndex !== index)
|
||||
: [...prev, index],
|
||||
);
|
||||
setPresetsOpenIndex(null);
|
||||
}
|
||||
|
||||
function commitTag(index: number) {
|
||||
|
|
@ -191,35 +209,90 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
|
|||
onClose();
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[101] flex items-center justify-center bg-black/20 backdrop-blur-xs">
|
||||
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 pt-5 pb-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
<span>Tabular Review</span>
|
||||
<span>›</span>
|
||||
<span>{isEditing ? "Edit column" : "New column"}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col min-h-0 flex-1"
|
||||
>
|
||||
{/* Body */}
|
||||
<div className="px-6 pt-3 pb-5 space-y-5 overflow-y-auto flex-1">
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
breadcrumbs={[
|
||||
"Tabular Review",
|
||||
isEditing ? "Edit column" : "New column",
|
||||
]}
|
||||
primaryAction={{
|
||||
label: isEditing ? "Save changes" : "Add columns",
|
||||
type: "submit",
|
||||
form: formId,
|
||||
disabled: columns.some(
|
||||
(col) => !col.name.trim() || !col.prompt.trim(),
|
||||
),
|
||||
}}
|
||||
cancelAction={{ label: "Cancel", onClick: handleClose }}
|
||||
secondaryAction={
|
||||
isEditing && onDelete
|
||||
? {
|
||||
label: "Delete",
|
||||
variant: "danger",
|
||||
onClick: onDelete,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={handleSubmit}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-3">
|
||||
{columns.map((column, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-xl border border-gray-200 p-4"
|
||||
className="relative"
|
||||
>
|
||||
{(() => {
|
||||
const nameInputId = `column-${index}-name`;
|
||||
const formatInputId = `column-${index}-format`;
|
||||
const tagInputId = `column-${index}-tag`;
|
||||
const promptInputId = `column-${index}-prompt`;
|
||||
const isCollapsed =
|
||||
collapsedIndices.includes(index);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toggleColumnCollapsed(
|
||||
index,
|
||||
)
|
||||
}
|
||||
aria-expanded={!isCollapsed}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-lg text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-gray-300"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 shrink-0 text-gray-600 transition-transform ${isCollapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
<h3 className="font-serif text-2xl text-gray-950">
|
||||
Column {index + 1}
|
||||
</h3>
|
||||
</button>
|
||||
{columns.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
removeColumn(index)
|
||||
}
|
||||
className="rounded-lg p-1.5 text-gray-300 transition-colors hover:bg-gray-100 hover:text-gray-500"
|
||||
aria-label="Remove column"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<ModalFieldLabel htmlFor={nameInputId}>
|
||||
Column title
|
||||
</ModalFieldLabel>
|
||||
{/* Name row */}
|
||||
<div className="flex items-start gap-2">
|
||||
{/* Input + preset dropdown anchored to this wrapper */}
|
||||
|
|
@ -231,8 +304,10 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
|
|||
: null
|
||||
}
|
||||
>
|
||||
<input
|
||||
<ModalTextInput
|
||||
id={nameInputId}
|
||||
type="text"
|
||||
variant="minimal"
|
||||
value={column.name}
|
||||
onChange={(e) => {
|
||||
const name = e.target.value;
|
||||
|
|
@ -253,7 +328,7 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
|
|||
});
|
||||
}}
|
||||
placeholder="Column name"
|
||||
className="flex-1 text-2xl font-serif text-gray-800 placeholder-gray-400 focus:outline-none bg-transparent"
|
||||
className="flex-1"
|
||||
autoFocus={index === 0}
|
||||
/>
|
||||
<button
|
||||
|
|
@ -316,74 +391,39 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
{columns.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeColumn(index)}
|
||||
className="mt-1.5 rounded-lg p-1.5 text-gray-300 transition-colors hover:bg-gray-100 hover:text-gray-500"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Format */}
|
||||
<div className="mt-4">
|
||||
<ModalFieldLabel className="text-gray-500">
|
||||
<ModalFieldLabel htmlFor={formatInputId}>
|
||||
Format
|
||||
</ModalFieldLabel>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="mt-1 flex items-center justify-between rounded-md border border-gray-200 bg-white px-2 py-1.5 text-sm text-gray-700 hover:border-gray-400 focus:outline-none">
|
||||
<span className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const Icon = formatIcon(
|
||||
column.format,
|
||||
);
|
||||
return (
|
||||
<Icon className="h-3.5 w-3.5 text-gray-400" />
|
||||
);
|
||||
})()}
|
||||
{formatLabel(column.format)}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-gray-400" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="z-[200]"
|
||||
>
|
||||
<DropdownMenuRadioGroup
|
||||
value={column.format}
|
||||
onValueChange={(v) =>
|
||||
updateColumn(index, {
|
||||
format: v as ColumnFormat,
|
||||
tags: [],
|
||||
tagInput: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
{FORMAT_OPTIONS.map((o) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={o.value}
|
||||
value={o.value}
|
||||
>
|
||||
<o.icon className="h-3.5 w-3.5 text-gray-400" />
|
||||
{o.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ModalSelect
|
||||
id={formatInputId}
|
||||
value={column.format}
|
||||
options={FORMAT_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
icon: option.icon,
|
||||
iconClassName: option.iconClassName,
|
||||
}))}
|
||||
onChange={(value) =>
|
||||
updateColumn(index, {
|
||||
format: value as ColumnFormat,
|
||||
tags: [],
|
||||
tagInput: "",
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tag input */}
|
||||
{column.format === "tag" && (
|
||||
<div className="mt-3">
|
||||
<ModalFieldLabel className="text-gray-500">
|
||||
<ModalFieldLabel htmlFor={tagInputId}>
|
||||
Tags
|
||||
</ModalFieldLabel>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5 rounded-md border border-gray-200 px-2 py-1.5 focus-within:border-gray-400">
|
||||
<div className="mt-1 flex flex-wrap gap-1.5 rounded-xl border border-white/70 bg-white/55 px-2 py-1.5 shadow-[0_3px_9px_rgba(15,23,42,0.052),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-xl">
|
||||
{column.tags.map((tag, tagIdx) => (
|
||||
<span
|
||||
key={tag}
|
||||
|
|
@ -410,8 +450,10 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
|
|||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
<ModalTextInput
|
||||
id={tagInputId}
|
||||
type="text"
|
||||
variant="minimal"
|
||||
value={column.tagInput}
|
||||
onChange={(e) =>
|
||||
updateColumn(index, {
|
||||
|
|
@ -424,7 +466,7 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
|
|||
}
|
||||
onBlur={() => commitTag(index)}
|
||||
placeholder="Add tag…"
|
||||
className="min-w-[80px] flex-1 bg-transparent text-sm text-gray-700 placeholder-gray-400 focus:outline-none"
|
||||
className="min-w-[80px] flex-1 bg-transparent font-sans text-sm text-gray-700 shadow-none placeholder:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
|
|
@ -435,7 +477,10 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
|
|||
|
||||
{/* Prompt */}
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<ModalFieldLabel className="mb-0 text-gray-500">
|
||||
<ModalFieldLabel
|
||||
htmlFor={promptInputId}
|
||||
className="mb-0"
|
||||
>
|
||||
Prompt
|
||||
</ModalFieldLabel>
|
||||
<button
|
||||
|
|
@ -457,7 +502,8 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
|
|||
Auto-Generate Prompt
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
<ModalTextarea
|
||||
id={promptInputId}
|
||||
rows={6}
|
||||
value={column.prompt}
|
||||
onChange={(e) =>
|
||||
|
|
@ -466,8 +512,13 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
|
|||
})
|
||||
}
|
||||
placeholder="Write the analysis prompt — describe what Mike should extract from each document for this column…"
|
||||
className="mt-2 w-full rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-700 placeholder-gray-400 focus:border-gray-400 focus:outline-none bg-transparent resize-none leading-relaxed"
|
||||
className="mt-2 min-h-36"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
|
@ -481,43 +532,8 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
|
|||
Add another column
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
|
||||
<div>
|
||||
{isEditing && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="rounded-lg px-4 py-2 text-sm text-red-500 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="rounded-lg px-4 py-2 text-sm text-gray-500 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={columns.some(
|
||||
(col) => !col.name.trim() || !col.prompt.trim(),
|
||||
)}
|
||||
className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{isEditing ? "Save changes" : "Add columns"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
465
frontend/src/app/components/tabular/NewTRModal.tsx
Normal file
465
frontend/src/app/components/tabular/NewTRModal.tsx
Normal file
|
|
@ -0,0 +1,465 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Loader2, Upload } from "lucide-react";
|
||||
import type { Document, Project, Workflow } from "../shared/types";
|
||||
import {
|
||||
getProject,
|
||||
listProjects,
|
||||
listStandaloneDocuments,
|
||||
listWorkflows,
|
||||
uploadProjectDocument,
|
||||
uploadStandaloneDocument,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import { FileDirectory } from "../shared/FileDirectory";
|
||||
import { Modal } from "../modals/Modal";
|
||||
import { ModalFieldLabel } from "../modals/ModalFieldLabel";
|
||||
import { ModalSelect } from "../modals/ModalSelect";
|
||||
import { ModalTextInput } from "../modals/ModalTextInput";
|
||||
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
const devLog = (...args: Parameters<typeof console.log>) => {
|
||||
if (isDev) console.log(...args);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAdd: (
|
||||
title: string,
|
||||
projectId?: string,
|
||||
documentIds?: string[],
|
||||
columnsConfig?: Workflow["columns_config"],
|
||||
) => void;
|
||||
projects?: Project[];
|
||||
/** When provided, skip the project/directory picker and show only these docs */
|
||||
projectDocs?: Document[];
|
||||
projectName?: string;
|
||||
projectCmNumber?: string | null;
|
||||
}
|
||||
|
||||
export function NewTRModal({
|
||||
open,
|
||||
onClose,
|
||||
onAdd,
|
||||
projects = [],
|
||||
projectDocs: fixedProjectDocs,
|
||||
projectName,
|
||||
projectCmNumber,
|
||||
}: Props) {
|
||||
const isProjectMode = fixedProjectDocs !== undefined;
|
||||
const [step, setStep] = useState<"details" | "documents">("details");
|
||||
const [title, setTitle] = useState("");
|
||||
const [underProject, setUnderProject] = useState(false);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState("");
|
||||
|
||||
// Project-scoped docs (when underProject is true and no fixedProjectDocs)
|
||||
const [projectDocs, setProjectDocs] = useState<Document[]>([]);
|
||||
const [loadingDocs, setLoadingDocs] = useState(false);
|
||||
|
||||
// Full directory (when underProject is false)
|
||||
const [standaloneDocs, setStandaloneDocs] = useState<Document[]>([]);
|
||||
const [directoryProjects, setDirectoryProjects] = useState<Project[]>(
|
||||
[],
|
||||
);
|
||||
const [loadingDirectory, setLoadingDirectory] = useState(false);
|
||||
|
||||
const [selectedDocIds, setSelectedDocIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Workflow templates
|
||||
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
||||
const [loadingWorkflows, setLoadingWorkflows] = useState(false);
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const formId = "new-tabular-review-modal-form";
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
setLoadingWorkflows(true);
|
||||
listWorkflows("tabular")
|
||||
.then((workflows) => {
|
||||
devLog("[workflows/ui:tabular-review-modal] loaded", {
|
||||
workflowCount: workflows.length,
|
||||
systemCount: workflows.filter((workflow) => workflow.is_system)
|
||||
.length,
|
||||
sample: workflows.slice(0, 5).map((workflow) => ({
|
||||
id: workflow.id,
|
||||
title: workflow.metadata.title,
|
||||
type: workflow.metadata.type,
|
||||
user_id: workflow.user_id,
|
||||
is_system: workflow.is_system,
|
||||
is_owner: workflow.is_owner,
|
||||
})),
|
||||
});
|
||||
setWorkflows(workflows);
|
||||
})
|
||||
.catch((error) => {
|
||||
devLog(
|
||||
"[workflows/ui:tabular-review-modal] failed",
|
||||
error,
|
||||
);
|
||||
setWorkflows([]);
|
||||
})
|
||||
.finally(() => setLoadingWorkflows(false));
|
||||
|
||||
if (isProjectMode) {
|
||||
setSelectedDocIds(
|
||||
new Set((fixedProjectDocs ?? []).map((d) => d.id)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingDirectory(true);
|
||||
// /projects only returns counts, not the documents array — fetch
|
||||
// each project in parallel so FileDirectory can render the docs
|
||||
// when the user expands a folder.
|
||||
Promise.all([listStandaloneDocuments(), listProjects()])
|
||||
.then(async ([docs, projs]) => {
|
||||
setStandaloneDocs(
|
||||
[...docs].sort((a, b) =>
|
||||
(b.created_at ?? "").localeCompare(a.created_at ?? ""),
|
||||
),
|
||||
);
|
||||
const fullProjects = await Promise.all(
|
||||
projs.map((p) => getProject(p.id)),
|
||||
);
|
||||
setDirectoryProjects(fullProjects);
|
||||
})
|
||||
.catch(() => {
|
||||
setStandaloneDocs([]);
|
||||
setDirectoryProjects([]);
|
||||
})
|
||||
.finally(() => setLoadingDirectory(false));
|
||||
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function handleClose() {
|
||||
setStep("details");
|
||||
setTitle("");
|
||||
setUnderProject(false);
|
||||
setSelectedProjectId("");
|
||||
setProjectDocs([]);
|
||||
setStandaloneDocs([]);
|
||||
setDirectoryProjects([]);
|
||||
setSelectedDocIds(new Set());
|
||||
setSelectedWorkflowId(null);
|
||||
onClose();
|
||||
}
|
||||
|
||||
function submitterValue(e: React.FormEvent<HTMLFormElement>) {
|
||||
return (
|
||||
(e.nativeEvent as SubmitEvent).submitter as
|
||||
| HTMLButtonElement
|
||||
| null
|
||||
)?.value;
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
if (underProject && !selectedProjectId) return;
|
||||
if (step === "details" || submitterValue(e) !== "create-review") {
|
||||
setStep("documents");
|
||||
return;
|
||||
}
|
||||
const selectedWorkflow = workflows.find(
|
||||
(w) => w.id === selectedWorkflowId,
|
||||
);
|
||||
onAdd(
|
||||
title.trim(),
|
||||
underProject ? selectedProjectId : undefined,
|
||||
selectedDocIds.size > 0 ? [...selectedDocIds] : undefined,
|
||||
selectedWorkflow?.columns_config ?? undefined,
|
||||
);
|
||||
handleClose();
|
||||
}
|
||||
|
||||
async function handleSelectProject(projectId: string) {
|
||||
setSelectedProjectId(projectId);
|
||||
setProjectDocs([]);
|
||||
setSelectedDocIds(new Set());
|
||||
setLoadingDocs(true);
|
||||
try {
|
||||
const proj = await getProject(projectId);
|
||||
const docs = (proj.documents ?? []).filter(
|
||||
(d) => d.status === "ready",
|
||||
);
|
||||
setProjectDocs(docs);
|
||||
setSelectedDocIds(new Set(docs.map((d) => d.id)));
|
||||
} finally {
|
||||
setLoadingDocs(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (!files.length) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploaded = await Promise.all(
|
||||
files.map((f) =>
|
||||
underProject && selectedProjectId
|
||||
? uploadProjectDocument(selectedProjectId, f)
|
||||
: uploadStandaloneDocument(f),
|
||||
),
|
||||
);
|
||||
if (underProject && selectedProjectId) {
|
||||
setProjectDocs((prev) => [...uploaded, ...prev]);
|
||||
} else {
|
||||
setStandaloneDocs((prev) => [...uploaded, ...prev]);
|
||||
}
|
||||
uploaded.forEach((d) =>
|
||||
setSelectedDocIds((prev) => new Set([...prev, d.id])),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Upload failed:", err);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
const workflowOptions = [
|
||||
{
|
||||
value: "",
|
||||
label: loadingWorkflows
|
||||
? "Loading templates..."
|
||||
: "No template - start from scratch",
|
||||
},
|
||||
...workflows.map((workflow) => ({
|
||||
value: workflow.id,
|
||||
label: workflow.metadata.title,
|
||||
})),
|
||||
];
|
||||
const projectOptions = projects.length
|
||||
? projects.map((project) => ({
|
||||
value: project.id,
|
||||
label:
|
||||
project.name +
|
||||
(project.cm_number ? ` (#${project.cm_number})` : ""),
|
||||
}))
|
||||
: [{ value: "", label: "No projects found" }];
|
||||
|
||||
// What to show in the directory depends on mode and toggle state
|
||||
const directoryStandalone = isProjectMode
|
||||
? (fixedProjectDocs ?? [])
|
||||
: underProject
|
||||
? []
|
||||
: standaloneDocs;
|
||||
const directoryFolders = isProjectMode
|
||||
? []
|
||||
: underProject
|
||||
? []
|
||||
: directoryProjects;
|
||||
const flatProjectDocs: Document[] =
|
||||
!isProjectMode && underProject ? projectDocs : [];
|
||||
const directoryLoading = isProjectMode
|
||||
? false
|
||||
: underProject
|
||||
? loadingDocs
|
||||
: loadingDirectory;
|
||||
const showDirectory = isProjectMode || !underProject || !!selectedProjectId;
|
||||
const breadcrumbs =
|
||||
isProjectMode && projectName
|
||||
? [
|
||||
"Projects",
|
||||
`${projectName}${projectCmNumber ? ` (#${projectCmNumber})` : ""}`,
|
||||
"New Tabular Review",
|
||||
]
|
||||
: ["Tabular Reviews", "New Tabular Review"];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
breadcrumbs={[
|
||||
...breadcrumbs,
|
||||
step === "details" ? "Details" : "Add Documents",
|
||||
]}
|
||||
secondaryAction={
|
||||
step === "documents"
|
||||
? {
|
||||
label: uploading ? "Uploading..." : "Upload",
|
||||
icon: uploading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
),
|
||||
onClick: () => fileInputRef.current?.click(),
|
||||
disabled: uploading,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
cancelAction={
|
||||
step === "documents"
|
||||
? {
|
||||
label: "Back",
|
||||
onClick: () => setStep("details"),
|
||||
disabled: uploading,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
primaryAction={
|
||||
step === "details"
|
||||
? {
|
||||
label: "Next",
|
||||
type: "button",
|
||||
onClick: (event) => {
|
||||
event.preventDefault();
|
||||
setStep("documents");
|
||||
},
|
||||
disabled:
|
||||
!title.trim() ||
|
||||
(underProject && !selectedProjectId),
|
||||
}
|
||||
: {
|
||||
label: "Create",
|
||||
type: "submit",
|
||||
form: formId,
|
||||
name: "modalAction",
|
||||
value: "create-review",
|
||||
disabled:
|
||||
!title.trim() ||
|
||||
(underProject && !selectedProjectId),
|
||||
}
|
||||
}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.docx,.doc,.xlsx,.xlsm,.xls,.pptx,.ppt"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col min-h-0 flex-1"
|
||||
>
|
||||
{step === "details" ? (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<ModalFieldLabel htmlFor="new-tr-title">
|
||||
Review name
|
||||
</ModalFieldLabel>
|
||||
<ModalTextInput
|
||||
id="new-tr-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Review name"
|
||||
variant="minimal"
|
||||
className="placeholder:text-gray-400"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Workflow template */}
|
||||
<div>
|
||||
<ModalFieldLabel as="p">
|
||||
Workflow template
|
||||
</ModalFieldLabel>
|
||||
<ModalSelect
|
||||
id="new-tr-workflow-template"
|
||||
value={selectedWorkflowId ?? ""}
|
||||
options={workflowOptions}
|
||||
onChange={(value) =>
|
||||
setSelectedWorkflowId(value || null)
|
||||
}
|
||||
disabled={loadingWorkflows}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Create under a project toggle */}
|
||||
{!isProjectMode && (
|
||||
<div className="space-y-3">
|
||||
<ModalFieldLabel as="p">
|
||||
Project
|
||||
</ModalFieldLabel>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = !underProject;
|
||||
setUnderProject(next);
|
||||
if (!next) {
|
||||
setSelectedProjectId("");
|
||||
setProjectDocs([]);
|
||||
setSelectedDocIds(new Set());
|
||||
}
|
||||
}}
|
||||
className="flex w-fit items-center gap-2.5"
|
||||
>
|
||||
<span
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors duration-200 ${underProject ? "bg-gray-900" : "bg-gray-100"}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ${underProject ? "translate-x-4" : "translate-x-0"}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
Create under a project
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{underProject && (
|
||||
<ModalSelect
|
||||
id="new-tr-project"
|
||||
value={selectedProjectId}
|
||||
options={projectOptions}
|
||||
onChange={(value) => {
|
||||
if (value) {
|
||||
void handleSelectProject(value);
|
||||
}
|
||||
}}
|
||||
placeholder="Select project..."
|
||||
disabled={projects.length === 0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{showDirectory && (
|
||||
<FileDirectory
|
||||
standaloneDocs={
|
||||
isProjectMode
|
||||
? directoryStandalone
|
||||
: underProject
|
||||
? flatProjectDocs
|
||||
: directoryStandalone
|
||||
}
|
||||
directoryProjects={
|
||||
isProjectMode
|
||||
? []
|
||||
: underProject
|
||||
? []
|
||||
: directoryFolders
|
||||
}
|
||||
loading={directoryLoading}
|
||||
selectedIds={selectedDocIds}
|
||||
onChange={setSelectedDocIds}
|
||||
emptyMessage={
|
||||
isProjectMode || underProject
|
||||
? "No ready documents in this project"
|
||||
: "No documents yet"
|
||||
}
|
||||
searchable
|
||||
searchAutoFocus
|
||||
showProjectTabs={!isProjectMode && !underProject}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
ChevronLeft,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { MikeIcon } from "@/components/chat/mike-icon";
|
||||
import { MikeIcon } from "@/app/components/chat/mike-icon";
|
||||
import {
|
||||
streamTabularChat,
|
||||
getTabularChats,
|
||||
|
|
@ -27,14 +27,14 @@ import type { AssistantEvent, ColumnConfig, Document } from "../shared/types";
|
|||
import { ModelToggle } from "../assistant/ModelToggle";
|
||||
import { ApiKeyMissingPopup } from "../popups/ApiKeyMissingPopup";
|
||||
import { PreResponseWrapper } from "../assistant/PreResponseWrapper";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import { useUserProfile } from "@/app/contexts/UserProfileContext";
|
||||
import {
|
||||
getModelProvider,
|
||||
isModelAvailable,
|
||||
type ModelProvider,
|
||||
} from "@/app/lib/modelAvailability";
|
||||
import type { ApiKeyState } from "@/app/lib/mikeApi";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChevronDown, Loader2, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ChevronDown, Loader2, MoreHorizontal, Plus, X } from "lucide-react";
|
||||
import type { ColumnConfig, ColumnFormat } from "../shared/types";
|
||||
import { generateTabularColumnPrompt } from "@/app/lib/mikeApi";
|
||||
import { FORMAT_OPTIONS, formatLabel, formatIcon } from "./columnFormat";
|
||||
|
|
@ -12,10 +13,17 @@ import {
|
|||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
} from "@/app/components/ui/dropdown-menu";
|
||||
import { PillButton } from "@/app/components/ui/pill-button";
|
||||
|
||||
// Liquid-glass field styling shared by the menu's inputs/controls, matching the
|
||||
// modal's glass treatment (translucent white over the light-gray panel).
|
||||
const GLASS_FIELD =
|
||||
"border border-white/70 bg-white/55 shadow-[0_3px_9px_rgba(15,23,42,0.052),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-xl";
|
||||
|
||||
export interface TREditColumnMenuProps {
|
||||
column: ColumnConfig;
|
||||
closeSignal?: number;
|
||||
disabled?: boolean;
|
||||
onSave: (column: ColumnConfig) => void | Promise<void>;
|
||||
onDelete: (columnIndex: number) => void | Promise<void>;
|
||||
|
|
@ -23,11 +31,13 @@ export interface TREditColumnMenuProps {
|
|||
|
||||
export function TREditColumnMenu({
|
||||
column,
|
||||
closeSignal,
|
||||
disabled,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: TREditColumnMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuId = useId();
|
||||
const [name, setName] = useState(column.name);
|
||||
const [prompt, setPrompt] = useState(column.prompt);
|
||||
const [format, setFormat] = useState<ColumnFormat>(column.format ?? "text");
|
||||
|
|
@ -36,6 +46,58 @@ export function TREditColumnMenu({
|
|||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
// Fixed-position coords for the portaled menu. The menu is rendered into
|
||||
// document.body so it isn't clipped by the header's overflow-hidden (which
|
||||
// exists for horizontal scroll-sync). We size/position it to span the column
|
||||
// header cell (with a min width for usability) and keep it pinned as the
|
||||
// table scrolls or the window resizes.
|
||||
const [menuPos, setMenuPos] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setMenuPos(null);
|
||||
return;
|
||||
}
|
||||
const update = () => {
|
||||
const rect = buttonRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
// Span this column's header cell so the panel's left/right edges line
|
||||
// up with the column. Falls back to the trigger button when no column
|
||||
// ancestor is found. A min width keeps the form usable on narrow
|
||||
// columns (there it just extends leftward from the column's right
|
||||
// edge). Positioning via left (same coordinate space as
|
||||
// getBoundingClientRect) avoids scrollbar/innerWidth offsets.
|
||||
const colRect = buttonRef.current
|
||||
?.closest("[data-tr-col-header]")
|
||||
?.getBoundingClientRect();
|
||||
const rightEdge = colRect?.right ?? rect.right;
|
||||
const width = Math.max(colRect?.width ?? 288, 288); // 288 = w-72
|
||||
setMenuPos({
|
||||
top: rect.bottom + 6, // mt-1.5
|
||||
left: Math.max(8, rightEdge - width),
|
||||
width,
|
||||
});
|
||||
};
|
||||
update();
|
||||
window.addEventListener("scroll", update, true);
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", update, true);
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (closeSignal === undefined) return;
|
||||
const timeout = window.setTimeout(() => setOpen(false), 0);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [closeSignal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
|
|
@ -47,6 +109,45 @@ export function TREditColumnMenu({
|
|||
}
|
||||
}, [column.name, column.prompt, column.format, column.tags, open]);
|
||||
|
||||
// Only one edit-column menu should be open at a time. Broadcast when this
|
||||
// one opens and close ourselves when another one broadcasts.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("tr-edit-column-menu-open", { detail: menuId }),
|
||||
);
|
||||
}
|
||||
}, [open, menuId]);
|
||||
|
||||
useEffect(() => {
|
||||
const onOtherOpen = (e: Event) => {
|
||||
if ((e as CustomEvent<string>).detail !== menuId) setOpen(false);
|
||||
};
|
||||
window.addEventListener("tr-edit-column-menu-open", onOtherOpen);
|
||||
return () =>
|
||||
window.removeEventListener("tr-edit-column-menu-open", onOtherOpen);
|
||||
}, [menuId]);
|
||||
|
||||
// Close on click outside the panel. Ignore the trigger (it toggles) and any
|
||||
// Radix popper (e.g. the Format dropdown, which portals outside the panel).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
panelRef.current?.contains(target) ||
|
||||
buttonRef.current?.contains(target) ||
|
||||
(target instanceof Element &&
|
||||
target.closest("[data-radix-popper-content-wrapper]"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
return () => document.removeEventListener("mousedown", onPointerDown);
|
||||
}, [open]);
|
||||
|
||||
function commitTag() {
|
||||
const tag = tagInput.trim();
|
||||
if (!tag) {
|
||||
|
|
@ -112,6 +213,7 @@ export function TREditColumnMenu({
|
|||
return (
|
||||
<div className="relative shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (disabled) return;
|
||||
|
|
@ -127,19 +229,28 @@ export function TREditColumnMenu({
|
|||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 top-full z-20 mt-1.5 w-72 rounded-2xl border border-white/70 bg-white p-3 shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18)] backdrop-blur-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{open &&
|
||||
menuPos &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="fixed z-[40] rounded-3xl border border-white/70 bg-gray-50/95 p-3 shadow-[0_14px_40px_rgba(15,23,42,0.071),0_5px_14px_rgba(15,23,42,0.047)] backdrop-blur-3xl"
|
||||
style={{
|
||||
top: menuPos.top,
|
||||
left: menuPos.left,
|
||||
width: menuPos.width,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm font-medium text-gray-800">
|
||||
<p className="font-serif text-lg font-medium text-gray-900">
|
||||
Edit Column
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
|
||||
aria-label="Close"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-white/70 bg-white/55 text-gray-500 shadow-[inset_0_1px_0_rgba(255,255,255,0.75),inset_0_-1px_0_rgba(255,255,255,0.55),0_6px_18px_rgba(15,23,42,0.08)] backdrop-blur-xl transition-colors hover:bg-white/75 hover:text-gray-700"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
|
@ -151,7 +262,7 @@ export function TREditColumnMenu({
|
|||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-gray-200 px-2 py-1 text-gray-800 text-xs font-normal focus:border-gray-400 focus:outline-none"
|
||||
className={`mt-1 w-full rounded-lg px-2 py-1 text-xs font-normal text-gray-800 transition-colors focus:bg-white/70 focus:outline-none ${GLASS_FIELD}`}
|
||||
/>
|
||||
|
||||
{/* Format */}
|
||||
|
|
@ -161,7 +272,9 @@ export function TREditColumnMenu({
|
|||
</label>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="mt-1 flex w-full items-center justify-between rounded-md border border-gray-200 bg-white px-2 py-1 text-xs text-gray-700 hover:border-gray-400 focus:outline-none">
|
||||
<button
|
||||
className={`mt-1 flex w-full items-center justify-between rounded-lg px-2 py-1 text-xs text-gray-700 transition-colors hover:bg-white/75 focus:outline-none ${GLASS_FIELD}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{(() => {
|
||||
const Icon = formatIcon(format);
|
||||
|
|
@ -176,6 +289,7 @@ export function TREditColumnMenu({
|
|||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="z-[50] border-white/70 bg-white/75 shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18)] backdrop-blur-2xl"
|
||||
style={{
|
||||
width: "var(--radix-dropdown-menu-trigger-width)",
|
||||
}}
|
||||
|
|
@ -206,7 +320,9 @@ export function TREditColumnMenu({
|
|||
{/* Tag input */}
|
||||
{format === "tag" && (
|
||||
<div className="mt-2">
|
||||
<div className="flex flex-wrap gap-1 rounded-md border border-gray-200 px-2 py-1 focus-within:border-gray-400 min-h-[28px]">
|
||||
<div
|
||||
className={`flex min-h-[28px] flex-wrap gap-1 rounded-lg px-2 py-1 transition-colors focus-within:bg-white/70 ${GLASS_FIELD}`}
|
||||
>
|
||||
{tags.map((tag, tagIdx) => (
|
||||
<span
|
||||
key={tag}
|
||||
|
|
@ -269,20 +385,18 @@ export function TREditColumnMenu({
|
|||
rows={6}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="mt-2 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-normal text-gray-800 placeholder-gray-300 focus:border-gray-400 focus:outline-none resize-none leading-relaxed"
|
||||
className={`mt-2 w-full resize-none rounded-lg px-3 py-2 text-xs font-normal leading-relaxed text-gray-800 placeholder-gray-300 transition-colors focus:bg-white/70 focus:outline-none ${GLASS_FIELD}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
<PillButton
|
||||
tone="danger"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting || saving}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-red-500 transition-colors hover:text-red-600 disabled:text-red-300"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
</PillButton>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
|
|
@ -298,8 +412,9 @@ export function TREditColumnMenu({
|
|||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { getPillClass } from "./pillUtils";
|
|||
import { PdfView } from "../shared/views/PdfView";
|
||||
import { SpreadsheetView } from "../shared/views/SpreadsheetView";
|
||||
import { DocxView } from "../shared/views/DocxView";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/app/lib/utils";
|
||||
|
||||
function isDocxDocument(d: {
|
||||
file_type?: string | null;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { forwardRef, useImperativeHandle, useRef } from "react";
|
||||
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
|
||||
import { Loader2, Plus, Table2, Upload } from "lucide-react";
|
||||
import type {
|
||||
ColumnConfig,
|
||||
|
|
@ -75,18 +75,31 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
const stickyCellBg = "bg-[#fafbfc]";
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const lastScrollLeftRef = useRef(0);
|
||||
const [scrollCloseSignal, setScrollCloseSignal] = useState(0);
|
||||
|
||||
function syncHeader() {
|
||||
if (headerRef.current && scrollContainerRef.current) {
|
||||
headerRef.current.scrollLeft = scrollContainerRef.current.scrollLeft;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRowsScroll() {
|
||||
syncHeader();
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
if (container.scrollLeft !== lastScrollLeftRef.current) {
|
||||
lastScrollLeftRef.current = container.scrollLeft;
|
||||
setScrollCloseSignal((signal) => signal + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const sortedColumns = [...columns].sort((a, b) => a.index - b.index);
|
||||
const totalContentWidth =
|
||||
DOC_COL_W_PX + sortedColumns.length * DATA_COL_W_PX + 32;
|
||||
const skeletonContentWidth =
|
||||
DOC_COL_W_PX + SKELETON_COLS * DATA_COL_W_PX + 32;
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToCell(colIdx: number, rowIdx: number) {
|
||||
const container = scrollContainerRef.current;
|
||||
|
|
@ -269,12 +282,14 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
{columns.map((col) => (
|
||||
<div
|
||||
key={col.index}
|
||||
data-tr-col-header
|
||||
className={`${COL_W} border-b border-r border-gray-200 p-2 text-left text-xs font-medium text-gray-500 select-none`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="truncate">{col.name}</span>
|
||||
<TREditColumnMenu
|
||||
column={col}
|
||||
closeSignal={scrollCloseSignal}
|
||||
disabled={savingColumn || savingColumnsConfig}
|
||||
onSave={onUpdateColumn}
|
||||
onDelete={onDeleteColumn}
|
||||
|
|
@ -298,7 +313,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
<div
|
||||
className="flex flex-1 flex-col overflow-auto min-h-0"
|
||||
ref={scrollContainerRef}
|
||||
onScroll={syncHeader}
|
||||
onScroll={handleRowsScroll}
|
||||
>
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{dragOverFiles && (
|
||||
|
|
@ -379,6 +394,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
<TabularCellComponent
|
||||
cell={cell}
|
||||
column={col}
|
||||
closeSignal={scrollCloseSignal}
|
||||
onExpand={() => onExpand(cell)}
|
||||
onCitationClick={(
|
||||
page,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { SkeletonLine } from "../shared/TablePrimitive";
|
|||
interface Props {
|
||||
cell: TCell;
|
||||
column?: ColumnConfig;
|
||||
closeSignal?: number;
|
||||
onExpand: () => void;
|
||||
onCitationClick?: (page: number, quote: string) => void;
|
||||
}
|
||||
|
|
@ -158,12 +159,19 @@ function CellMarkdown({
|
|||
export function TabularCell({
|
||||
cell,
|
||||
column,
|
||||
closeSignal,
|
||||
onExpand,
|
||||
onCitationClick,
|
||||
}: Props) {
|
||||
const [inlineExpanded, setInlineExpanded] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (closeSignal === undefined) return;
|
||||
const timeout = window.setTimeout(() => setInlineExpanded(false), 0);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [closeSignal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inlineExpanded) return;
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
|
|
@ -240,7 +248,7 @@ export function TabularCell({
|
|||
|
||||
{/* Inline expanded overlay — absolutely positioned so it overlays without disrupting table layout */}
|
||||
{inlineExpanded && (
|
||||
<div className="absolute left-0 top-0 z-50 w-full bg-white border border-gray-200 shadow-lg rounded-sm">
|
||||
<div className="absolute left-0 top-0 z-50 w-full rounded-xl bg-gray-50/95 shadow-[0_4px_12px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18)] backdrop-blur-2xl">
|
||||
<div className="relative p-2 pr-4 text-xs text-gray-800 leading-relaxed">
|
||||
{cell.content.flag && (
|
||||
<span
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue