mirror of
https://github.com/willchen96/mike.git
synced 2026-07-24 23:41:04 +02:00
Sync workflow, asst extra input, excel + ppt support and modal updates
This commit is contained in:
parent
93f921be90
commit
a5fe6d6e04
140 changed files with 13508 additions and 16700 deletions
171
frontend/package-lock.json
generated
171
frontend/package-lock.json
generated
|
|
@ -10,6 +10,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",
|
||||
|
|
@ -18,6 +20,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",
|
||||
|
|
@ -31,6 +35,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",
|
||||
|
|
@ -2538,6 +2543,90 @@
|
|||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@formulajs/formulajs": {
|
||||
"version": "2.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@formulajs/formulajs/-/formulajs-2.9.3.tgz",
|
||||
"integrity": "sha512-WpgiuJaBl/Hcda9Ti8a7mlnw/vUZkJrtjABvojz6P6mo6d8EscudyA7iAt/kTLsgi0c8zfmzQd/Yjb4ASFkw+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bessel": "^1.0.2",
|
||||
"jstat": "^1.9.2"
|
||||
},
|
||||
"bin": {
|
||||
"implementation-stats": "bin/implementation-stats"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortune-sheet/core": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@fortune-sheet/core/-/core-1.0.4.tgz",
|
||||
"integrity": "sha512-CDnUVebfvtT++CymNRw0qnY4sz6zYO3KZazlH+nG6otkRkZ05Bvt7NXqVhmKKSSxIvJR4R6h50abu/dVGBpjpw==",
|
||||
"license": "MIT",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortune-sheet/core/node_modules/immer": {
|
||||
"version": "9.0.21",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
|
||||
"integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortune-sheet/core/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortune-sheet/formula-parser": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@fortune-sheet/formula-parser/-/formula-parser-0.2.13.tgz",
|
||||
"integrity": "sha512-za2ZVQ5ZfMSPCtL8MdqhCthPip7AoeU4MPyd5UDo4RCl9/arrv1Jz0y755mACVVi4suSZxrzWoJGDkLmofoSmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formulajs/formulajs": "^2.9.3",
|
||||
"tiny-emitter": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortune-sheet/react": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@fortune-sheet/react/-/react-1.0.4.tgz",
|
||||
"integrity": "sha512-v+BU5mmp2hhUe4gRF+vOJ3j8zZkzHU0kzhTZzp+Nn00wA/RArMr+CO+SAluKlCPAsTZgPwmgOEK685WPqJ5XNw==",
|
||||
"license": "MIT",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortune-sheet/react/node_modules/immer": {
|
||||
"version": "9.0.21",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
|
||||
"integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
|
|
@ -6061,6 +6150,20 @@
|
|||
"@tiptap/core": "^3.22.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table": {
|
||||
"version": "3.22.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.22.3.tgz",
|
||||
"integrity": "sha512-inbQSusJad7H0T++L1APg/anfL5d15cNGp2YG3vwo6TQr71nn2c9pepvmz3xuAIt8eygZDRba+4GT/COP1f9QA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.22.3",
|
||||
"@tiptap/pm": "^3.22.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.22.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.3.tgz",
|
||||
|
|
@ -6425,6 +6528,12 @@
|
|||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/regenerator-runtime": {
|
||||
"version": "0.13.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/regenerator-runtime/-/regenerator-runtime-0.13.8.tgz",
|
||||
"integrity": "sha512-jjKoBekfYDH331060tZhosdJVDnXIXx+T8Iw2h2T4HEds6Ddb2lr0JxD15+XPKlXwRHRNgZoY+4Fb2ykoqzHBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/tough-cookie": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
||||
|
|
@ -7614,6 +7723,15 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/bessel": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bessel/-/bessel-1.0.2.tgz",
|
||||
"integrity": "sha512-Al3nHGQGqDYqqinXhQzmwmcRToe/3WyBv4N8aZc5Pef8xw2neZlR9VPi84Sa23JtgWcucu18HxVZrnI0fn2etw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/big-integer": {
|
||||
"version": "1.6.52",
|
||||
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
|
||||
|
|
@ -11748,6 +11866,11 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jstat": {
|
||||
"version": "1.9.6",
|
||||
"resolved": "https://registry.npmjs.org/jstat/-/jstat-1.9.6.tgz",
|
||||
"integrity": "sha512-rPBkJbK2TnA8pzs93QcDDPlKcrtZWuuCo2dVR0TFLOJSxhqfWOVCSp8aV3/oSbn+4uY4yw1URtLpHQedtmXfug=="
|
||||
},
|
||||
"node_modules/jsx-ast-utils": {
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||
|
|
@ -12173,6 +12296,12 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
|
|
@ -12311,6 +12440,15 @@
|
|||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/luckyexcel": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/luckyexcel/-/luckyexcel-1.0.1.tgz",
|
||||
"integrity": "sha512-hvbJmCXNp/vST/huA6sieDn32Ib8bd80L9aIu5ZGxniJvZle7VlpHZrl6weLGaEnX99+t7cPAoYGqrqbfZp/AQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jszip": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
|
@ -13783,6 +13921,15 @@
|
|||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/numeral": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/numeral/-/numeral-2.0.6.tgz",
|
||||
"integrity": "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
|
@ -14426,9 +14573,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/prosemirror-menu": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.1.tgz",
|
||||
"integrity": "sha512-2OSIKBFyLo2iqDpjQHEC7tKt3lluhY7L44pcRai8EpoU9R7cZDj/dklEsOOIubNKWUXab6dL7y4JtAWnrlR4lA==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.2.tgz",
|
||||
"integrity": "sha512-6VgUJTYod0nMBlCaYJGhXGLu7Gt4AvcwcOq0YfJCY/6Uh+3S7UsWhpy6rJFCBFOmonq1hD8KyWOtZhkppd4YPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crelt": "^1.0.0",
|
||||
|
|
@ -14438,9 +14585,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/prosemirror-model": {
|
||||
"version": "1.25.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||
"version": "1.25.9",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.9.tgz",
|
||||
"integrity": "sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
|
|
@ -14892,6 +15039,12 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||
|
|
@ -16275,6 +16428,12 @@
|
|||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-emitter": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
|
||||
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"prebuild": "node ../scripts/build-workflows.js",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
|
|
@ -15,6 +16,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",
|
||||
|
|
@ -23,6 +26,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",
|
||||
|
|
@ -36,6 +41,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",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { useUserProfile } from "@/contexts/UserProfileContext";
|
|||
import {
|
||||
MfaVerificationPopup,
|
||||
needsMfaVerification,
|
||||
} from "@/app/components/shared/MfaVerificationPopup";
|
||||
} from "@/app/components/popups/MfaVerificationPopup";
|
||||
import { isMfaRequiredError } from "@/app/lib/mikeApi";
|
||||
import {
|
||||
accountGlassIconButtonClassName,
|
||||
|
|
|
|||
|
|
@ -3,20 +3,19 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
Check,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Modal } from "@/app/components/shared/Modal";
|
||||
import { Modal } from "@/app/components/modals/Modal";
|
||||
import { NewMcpModal } from "@/app/components/account/NewMcpModal";
|
||||
import {
|
||||
MfaVerificationPopup,
|
||||
needsMfaVerification,
|
||||
} from "@/app/components/shared/MfaVerificationPopup";
|
||||
} from "@/app/components/popups/MfaVerificationPopup";
|
||||
import {
|
||||
type McpConnectorSummary,
|
||||
MikeApiError,
|
||||
|
|
@ -31,7 +30,6 @@ import {
|
|||
updateMcpConnector,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import {
|
||||
accountGlassDangerButtonClassName,
|
||||
accountGlassIconButtonClassName,
|
||||
accountGlassInputClassName,
|
||||
accountGlassPrimaryButtonClassName,
|
||||
|
|
@ -634,7 +632,7 @@ export default function ConnectorsPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<AddMcpConnectorModal
|
||||
<NewMcpModal
|
||||
open={addOpen}
|
||||
draft={addDraft}
|
||||
step={addStep}
|
||||
|
|
@ -793,129 +791,6 @@ function ConnectorRow({
|
|||
);
|
||||
}
|
||||
|
||||
function AddMcpConnectorModal({
|
||||
open,
|
||||
draft,
|
||||
step,
|
||||
result,
|
||||
error,
|
||||
authMessage,
|
||||
showToken,
|
||||
showAdvanced,
|
||||
onDraftChange,
|
||||
onShowTokenChange,
|
||||
onShowAdvancedChange,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onOpenConnector,
|
||||
}: {
|
||||
open: boolean;
|
||||
draft: AddDraft;
|
||||
step: AddStep;
|
||||
result: McpConnectorSummary | null;
|
||||
error: string | null;
|
||||
authMessage: string | null;
|
||||
showToken: boolean;
|
||||
showAdvanced: boolean;
|
||||
onDraftChange: (draft: AddDraft) => void;
|
||||
onShowTokenChange: (show: boolean) => void;
|
||||
onShowAdvancedChange: (show: boolean) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: () => Promise<void>;
|
||||
onOpenConnector: (connectorId: string) => void;
|
||||
}) {
|
||||
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"
|
||||
: "Add 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 ? (
|
||||
<SuccessToolsList connector={result} />
|
||||
) : step === "auth" ? (
|
||||
<ConnectorAuthScreen
|
||||
message={
|
||||
authMessage ??
|
||||
"Complete authorization in the popup to finish connecting this MCP server."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4 pb-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
The assistant will have access to this MCP server and
|
||||
its enabled tools.
|
||||
</p>
|
||||
<ConnectorForm
|
||||
draft={draft}
|
||||
showToken={showToken}
|
||||
showAdvanced={showAdvanced}
|
||||
showTokenNote
|
||||
tokenPlaceholder="Bearer token"
|
||||
disabled={step === "working"}
|
||||
onDraftChange={(next) =>
|
||||
onDraftChange({
|
||||
name: next.name,
|
||||
serverUrl: next.serverUrl,
|
||||
bearerToken: next.bearerToken,
|
||||
customHeaders: next.customHeaders,
|
||||
})
|
||||
}
|
||||
onShowTokenChange={onShowTokenChange}
|
||||
onShowAdvancedChange={onShowAdvancedChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function McpConnectorDetailsModal({
|
||||
connector,
|
||||
draft,
|
||||
|
|
@ -1020,7 +895,7 @@ function McpConnectorDetailsModal({
|
|||
}
|
||||
>
|
||||
{connector && (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-5 pb-4">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto pb-4">
|
||||
<ConnectorForm
|
||||
draft={draft}
|
||||
showToken={showToken}
|
||||
|
|
@ -1290,41 +1165,6 @@ function ConnectorForm({
|
|||
);
|
||||
}
|
||||
|
||||
function SuccessToolsList({ 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" />
|
||||
<div className="min-w-0">
|
||||
<p className="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>
|
||||
<ScrollableToolList connector={connector} fill />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectorAuthScreen({ 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>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolListSkeleton({
|
||||
count,
|
||||
fill = false,
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
|
||||
import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
|
||||
import {
|
||||
MfaVerificationPopup,
|
||||
needsMfaVerification,
|
||||
} from "@/app/components/shared/MfaVerificationPopup";
|
||||
import { WarningPopup } from "@/app/components/shared/WarningPopup";
|
||||
} from "@/app/components/popups/MfaVerificationPopup";
|
||||
import { WarningPopup } from "@/app/components/popups/WarningPopup";
|
||||
import { deleteAccount, isMfaRequiredError } from "@/app/lib/mikeApi";
|
||||
import {
|
||||
accountGlassDangerOutlineButtonClassName,
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import { useState } from "react";
|
|||
import { Download, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
|
||||
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
|
||||
import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
|
||||
import {
|
||||
MfaVerificationPopup,
|
||||
needsMfaVerification,
|
||||
} from "@/app/components/shared/MfaVerificationPopup";
|
||||
} from "@/app/components/popups/MfaVerificationPopup";
|
||||
import {
|
||||
deleteAllChats,
|
||||
deleteAllProjects,
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@ import { supabase } from "@/lib/supabase";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import { isMfaRequiredError } from "@/app/lib/mikeApi";
|
||||
import { Modal } from "@/app/components/shared/Modal";
|
||||
import { Modal } from "@/app/components/modals/Modal";
|
||||
import {
|
||||
MfaVerificationPopup,
|
||||
needsMfaVerification,
|
||||
} from "@/app/components/shared/MfaVerificationPopup";
|
||||
} from "@/app/components/popups/MfaVerificationPopup";
|
||||
import {
|
||||
accountGlassPrimaryButtonClassName,
|
||||
} from "../accountStyles";
|
||||
|
|
@ -578,7 +578,7 @@ export default function SecurityPage() {
|
|||
<Modal
|
||||
open={setupModalOpen}
|
||||
onClose={() => void closeSetupModal()}
|
||||
title="Set up authenticator app"
|
||||
breadcrumbs={["Security", "Set up authenticator app"]}
|
||||
cancelAction={{
|
||||
label: enrollment ? "Back" : "Cancel",
|
||||
onClick: enrollment
|
||||
|
|
@ -601,7 +601,13 @@ export default function SecurityPage() {
|
|||
}
|
||||
}
|
||||
>
|
||||
<div className={enrollment ? "space-y-3 pt-2" : "space-y-5 pt-3"}>
|
||||
<div
|
||||
className={
|
||||
enrollment
|
||||
? "min-h-0 flex-1 space-y-3 overflow-y-auto pt-2"
|
||||
: "min-h-0 flex-1 space-y-5 overflow-y-auto pt-3"
|
||||
}
|
||||
>
|
||||
{!enrollment ? (
|
||||
<>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-400">
|
||||
|
|
|
|||
|
|
@ -39,9 +39,10 @@ import { AssistantMessage } from "@/app/components/assistant/AssistantMessage";
|
|||
import { ChatInput } from "@/app/components/assistant/ChatInput";
|
||||
import type { ChatInputHandle } from "@/app/components/assistant/ChatInput";
|
||||
import { ProjectExplorer } from "@/app/components/projects/ProjectExplorer";
|
||||
import { DocView } from "@/app/components/shared/DocView";
|
||||
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
|
||||
import { DocxView } from "@/app/components/shared/DocxView";
|
||||
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";
|
||||
|
|
@ -50,13 +51,16 @@ import { PageHeader } from "@/app/components/shared/PageHeader";
|
|||
import { HeaderActionsMenu } from "@/app/components/shared/HeaderActionsMenu";
|
||||
import type {
|
||||
CitationQuote,
|
||||
CitationAnnotation,
|
||||
Citation,
|
||||
Document,
|
||||
EditAnnotation,
|
||||
Message,
|
||||
Project,
|
||||
} from "@/app/components/shared/types";
|
||||
import { expandCitationToEntries } from "@/app/components/shared/types";
|
||||
import {
|
||||
expandCitationToEntries,
|
||||
isSpreadsheetFilename,
|
||||
} from "@/app/components/shared/types";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string; chatId: string }>;
|
||||
|
|
@ -92,6 +96,7 @@ const EXPLORER_MIN = 160;
|
|||
const EXPLORER_DEFAULT = 280;
|
||||
const CHAT_MIN = 320;
|
||||
const CHAT_DEFAULT = 420;
|
||||
const DEFAULT_ASSISTANT_BOTTOM_PADDING = 116;
|
||||
|
||||
function AssistantGreeting({ username }: { username: string }) {
|
||||
const { profile } = useUserProfile();
|
||||
|
|
@ -260,6 +265,11 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
const [initialMessages] = useState<Message[]>(newChatMessages ?? []);
|
||||
const { messages, isResponseLoading, handleChat, setMessages, cancel } =
|
||||
useAssistantChat({ initialMessages, chatId, projectId });
|
||||
const pendingInitialUserMessageRef = useRef<Message | null>(
|
||||
initialMessages.length === 1 && initialMessages[0].role === "user"
|
||||
? initialMessages[0]
|
||||
: null,
|
||||
);
|
||||
|
||||
const hasLoaded = useRef(false);
|
||||
const hasAutoSent = useRef(false);
|
||||
|
|
@ -349,19 +359,19 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
}, [chats, chatId]);
|
||||
|
||||
useEffect(() => {
|
||||
const pendingMessage = pendingInitialUserMessageRef.current;
|
||||
if (
|
||||
newChatMessages &&
|
||||
newChatMessages.length === 1 &&
|
||||
newChatMessages[0].role === "user" &&
|
||||
pendingMessage &&
|
||||
!hasAutoSent.current &&
|
||||
!isResponseLoading &&
|
||||
messages.length === 1
|
||||
) {
|
||||
hasAutoSent.current = true;
|
||||
pendingInitialUserMessageRef.current = null;
|
||||
setNewChatMessages(null);
|
||||
void handleChat(newChatMessages[0]);
|
||||
void handleChat(pendingMessage);
|
||||
}
|
||||
}, [newChatMessages, messages.length, isResponseLoading]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [messages.length, isResponseLoading, handleChat, setNewChatMessages]);
|
||||
|
||||
const scrollLatestUserToTop = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
|
|
@ -405,10 +415,17 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
const userEl = latestUserMessageRef.current;
|
||||
const containerEl = messagesContainerRef.current;
|
||||
if (!userEl || !containerEl) return;
|
||||
const messageGap = window.innerWidth < 768 ? 24 : 32;
|
||||
setMinHeight(
|
||||
`${Math.max(0, containerEl.clientHeight - 48 - userEl.offsetHeight - 16)}px`,
|
||||
`${Math.max(
|
||||
0,
|
||||
containerEl.clientHeight -
|
||||
messageGap * 3 -
|
||||
userEl.offsetHeight -
|
||||
DEFAULT_ASSISTANT_BOTTOM_PADDING,
|
||||
)}px`,
|
||||
);
|
||||
}, [messages.length, latestUserMessageRef.current]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [messages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabId) return;
|
||||
|
|
@ -489,7 +506,7 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
openTab(doc.id, doc.filename);
|
||||
};
|
||||
|
||||
const handleCitationClick = (citation: CitationAnnotation) => {
|
||||
const handleCitationClick = (citation: Citation) => {
|
||||
if (citation.kind === "case") return;
|
||||
openTab(
|
||||
citation.document_id,
|
||||
|
|
@ -876,7 +893,7 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.docx,.doc"
|
||||
accept=".pdf,.docx,.doc,.xlsx,.xlsm,.xls,.pptx,.ppt"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) =>
|
||||
|
|
@ -1101,8 +1118,16 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
rounded={false}
|
||||
bordered={false}
|
||||
/>
|
||||
) : isSpreadsheetFilename(activeTab.filename) ? (
|
||||
<SpreadsheetView
|
||||
key={activeTab.documentId}
|
||||
documentId={activeTab.documentId}
|
||||
versionId={activeTab.versionId}
|
||||
rounded={false}
|
||||
bordered={false}
|
||||
/>
|
||||
) : (
|
||||
<DocView
|
||||
<PdfView
|
||||
key={activeTab.documentId}
|
||||
doc={{ document_id: activeTab.documentId }}
|
||||
quotes={activeQuotes ?? undefined}
|
||||
|
|
@ -1132,7 +1157,7 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
{/* RIGHT: Assistant Panel */}
|
||||
<div
|
||||
style={{ width: chatWidth }}
|
||||
className="shrink-0 flex flex-col"
|
||||
className="relative shrink-0 flex flex-col"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleChatDrop}
|
||||
>
|
||||
|
|
@ -1166,8 +1191,11 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
) : (
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="flex-1 overflow-y-auto px-4 py-4 space-y-4 min-h-0"
|
||||
style={{ scrollbarGutter: "stable" }}
|
||||
className="flex-1 overflow-y-auto px-4 pt-6 md:pt-8 space-y-6 md:space-y-8 min-h-0"
|
||||
style={{
|
||||
paddingBottom: DEFAULT_ASSISTANT_BOTTOM_PADDING,
|
||||
scrollbarGutter: "stable",
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const lastUserIdx = messages
|
||||
|
|
@ -1188,20 +1216,20 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
>
|
||||
<UserMessage
|
||||
content={msg.content ?? ""}
|
||||
files={(msg as any).files}
|
||||
files={msg.files}
|
||||
workflow={msg.workflow}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<AssistantMessage
|
||||
key={i}
|
||||
content={msg.content ?? ""}
|
||||
events={msg.events}
|
||||
isStreaming={
|
||||
i === messages.length - 1 &&
|
||||
isResponseLoading
|
||||
}
|
||||
isError={!!(msg as any).error}
|
||||
annotations={msg.annotations}
|
||||
isError={!!msg.error}
|
||||
citations={msg.citations}
|
||||
citationStatus={
|
||||
msg.citationStatus
|
||||
}
|
||||
|
|
@ -1231,20 +1259,23 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
)}
|
||||
|
||||
{/* ChatInput */}
|
||||
<div className="shrink-0 px-4 pb-4">
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={cancel}
|
||||
isLoading={isResponseLoading}
|
||||
hideAddDocButton
|
||||
projectName={project?.name}
|
||||
projectCmNumber={project?.cm_number}
|
||||
/>
|
||||
<div className="absolute bottom-2 left-0 right-0 z-30 w-full md:bottom-3">
|
||||
<div className="pointer-events-none absolute -bottom-2 left-4 right-4 z-0 h-7 bg-white/50 backdrop-blur-[1px] md:-bottom-3" />
|
||||
<div className="relative z-20 w-full px-4">
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={cancel}
|
||||
isLoading={isResponseLoading}
|
||||
hideAddDocButton
|
||||
projectName={project?.name}
|
||||
projectCmNumber={project?.cm_number}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<OwnerOnlyModal
|
||||
<OwnerOnlyPopup
|
||||
open={!!ownerOnlyAction}
|
||||
action={ownerOnlyAction ?? undefined}
|
||||
onClose={() => setOwnerOnlyAction(null)}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
updateTabularReview,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import { ProjectReviewsTable } from "@/app/components/projects/ProjectReviewsTable";
|
||||
import { TabularReviewDetailsModal } from "@/app/components/tabular/TabularReviewDetailsModal";
|
||||
import {
|
||||
ProjectSectionToolbar,
|
||||
useProjectWorkspace,
|
||||
|
|
@ -70,10 +71,9 @@ export default function ProjectTabularReviewsPage({ params }: Props) {
|
|||
setProjectReviews,
|
||||
} = workspace;
|
||||
const [selectedReviewIds, setSelectedReviewIds] = useState<string[]>([]);
|
||||
const [renamingReviewId, setRenamingReviewId] = useState<string | null>(
|
||||
const [detailsReview, setDetailsReview] = useState<TabularReview | null>(
|
||||
null,
|
||||
);
|
||||
const [renameReviewValue, setRenameReviewValue] = useState("");
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
const docs = project?.documents ?? [];
|
||||
const reviews = useMemo(() => projectReviews ?? [], [projectReviews]);
|
||||
|
|
@ -94,16 +94,35 @@ export default function ProjectTabularReviewsPage({ params }: Props) {
|
|||
!allReviewsSelected &&
|
||||
filteredReviews.some((r) => selectedReviewIds.includes(r.id));
|
||||
|
||||
async function submitReviewRename(reviewId: string) {
|
||||
const trimmed = renameReviewValue.trim();
|
||||
setRenamingReviewId(null);
|
||||
if (!trimmed) return;
|
||||
await updateTabularReview(reviewId, { title: trimmed });
|
||||
function handleOpenDetails(review: TabularReview) {
|
||||
if (user?.id && review.user_id !== user.id) {
|
||||
setOwnerOnlyAction("edit tabular review details");
|
||||
return;
|
||||
}
|
||||
setDetailsReview(review);
|
||||
}
|
||||
|
||||
async function handleDetailsSave(values: {
|
||||
title: string;
|
||||
projectId?: string | null;
|
||||
}) {
|
||||
if (!detailsReview) return;
|
||||
if (user?.id && detailsReview.user_id !== user.id) {
|
||||
setOwnerOnlyAction("edit tabular review details");
|
||||
return;
|
||||
}
|
||||
const updated = await updateTabularReview(detailsReview.id, {
|
||||
title: values.title,
|
||||
project_id: projectId,
|
||||
});
|
||||
setProjectReviews((prev) =>
|
||||
(prev ?? []).map((review) =>
|
||||
review.id === reviewId ? { ...review, title: trimmed } : review,
|
||||
review.id === updated.id ? { ...review, ...updated } : review,
|
||||
),
|
||||
);
|
||||
setDetailsReview((current) =>
|
||||
current?.id === updated.id ? { ...current, ...updated } : current,
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDeleteReviewRow(review: TabularReview) {
|
||||
|
|
@ -164,8 +183,6 @@ export default function ProjectTabularReviewsPage({ params }: Props) {
|
|||
selectedReviewIds={selectedReviewIds}
|
||||
allReviewsSelected={allReviewsSelected}
|
||||
someReviewsSelected={someReviewsSelected}
|
||||
renamingReviewId={renamingReviewId}
|
||||
renameReviewValue={renameReviewValue}
|
||||
creatingReview={workspace.creatingReview}
|
||||
currentUserId={user?.id}
|
||||
loading={loading}
|
||||
|
|
@ -175,12 +192,22 @@ export default function ProjectTabularReviewsPage({ params }: Props) {
|
|||
`/projects/${projectId}/tabular-reviews/${reviewId}`,
|
||||
)
|
||||
}
|
||||
onOpenDetails={handleOpenDetails}
|
||||
onDeleteReview={handleDeleteReviewRow}
|
||||
onOwnerOnlyAction={setOwnerOnlyAction}
|
||||
submitReviewRename={submitReviewRename}
|
||||
setSelectedReviewIds={setSelectedReviewIds}
|
||||
setRenamingReviewId={setRenamingReviewId}
|
||||
setRenameReviewValue={setRenameReviewValue}
|
||||
/>
|
||||
<TabularReviewDetailsModal
|
||||
open={!!detailsReview}
|
||||
review={detailsReview}
|
||||
projects={project ? [project] : []}
|
||||
canEdit={
|
||||
!!detailsReview &&
|
||||
(!user?.id || detailsReview.user_id === user.id)
|
||||
}
|
||||
lockProject
|
||||
onClose={() => setDetailsReview(null)}
|
||||
onSave={handleDetailsSave}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,8 +16,9 @@ import {
|
|||
} from "@/app/lib/mikeApi";
|
||||
import type { TabularReview, Project } from "@/app/components/shared/types";
|
||||
import { TableToolbar } from "@/app/components/shared/TableToolbar";
|
||||
import { AddNewTRModal } from "@/app/components/tabular/AddNewTRModal";
|
||||
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
|
||||
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 { PageHeader } from "@/app/components/shared/PageHeader";
|
||||
import {
|
||||
|
|
@ -62,9 +63,10 @@ export default function TabularReviewsPage() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newTROpen, setNewTROpen] = useState(false);
|
||||
const [detailsReview, setDetailsReview] = useState<TabularReview | null>(
|
||||
null,
|
||||
);
|
||||
const [activeScope, setActiveScope] = useState<ReviewScope>("all");
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [projectFilter, setProjectFilter] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
|
|
@ -156,23 +158,35 @@ export default function TabularReviewsPage() {
|
|||
}
|
||||
};
|
||||
|
||||
async function handleRenameSubmit(reviewId: string) {
|
||||
const trimmed = renameValue.trim();
|
||||
if (!trimmed) {
|
||||
setRenamingId(null);
|
||||
function requestReviewDetails(review: TabularReview) {
|
||||
if (user?.id && review.user_id !== user.id) {
|
||||
setOwnerOnlyAction("edit tabular review details");
|
||||
return;
|
||||
}
|
||||
const review = reviews.find((r) => r.id === reviewId);
|
||||
if (review && user?.id && review.user_id !== user.id) {
|
||||
setRenamingId(null);
|
||||
setOwnerOnlyAction("rename this tabular review");
|
||||
setDetailsReview(review);
|
||||
}
|
||||
|
||||
async function handleDetailsSave(values: {
|
||||
title: string;
|
||||
projectId?: string | null;
|
||||
}) {
|
||||
if (!detailsReview) return;
|
||||
if (user?.id && detailsReview.user_id !== user.id) {
|
||||
setOwnerOnlyAction("edit tabular review details");
|
||||
return;
|
||||
}
|
||||
const updated = await updateTabularReview(detailsReview.id, {
|
||||
title: values.title,
|
||||
project_id: values.projectId ?? null,
|
||||
});
|
||||
setReviews((prev) =>
|
||||
prev.map((r) => (r.id === reviewId ? { ...r, title: trimmed } : r)),
|
||||
prev.map((review) =>
|
||||
review.id === updated.id ? { ...review, ...updated } : review,
|
||||
),
|
||||
);
|
||||
setDetailsReview((current) =>
|
||||
current?.id === updated.id ? { ...current, ...updated } : current,
|
||||
);
|
||||
setRenamingId(null);
|
||||
await updateTabularReview(reviewId, { title: trimmed });
|
||||
}
|
||||
|
||||
async function handleDeleteSelected() {
|
||||
|
|
@ -264,37 +278,40 @@ export default function TabularReviewsPage() {
|
|||
/>
|
||||
|
||||
{/* Table */}
|
||||
<TableScrollArea>
|
||||
<TableHeaderRow>
|
||||
<TableStickyCell header>
|
||||
{loading ? (
|
||||
<SkeletonDot />
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected;
|
||||
}}
|
||||
onChange={toggleAll}
|
||||
className={TABLE_CHECKBOX_CLASS}
|
||||
/>
|
||||
)}
|
||||
<span>Name</span>
|
||||
</TableStickyCell>
|
||||
<TableHeaderCell className="ml-auto w-24">
|
||||
Columns
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="w-24">Documents</TableHeaderCell>
|
||||
<TableHeaderCell className="w-40">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Project</span>
|
||||
{projectFilterButton}
|
||||
</div>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="w-32">Created</TableHeaderCell>
|
||||
<TableHeaderCell className="w-8" />
|
||||
</TableHeaderRow>
|
||||
<TableScrollArea
|
||||
header={
|
||||
<TableHeaderRow>
|
||||
<TableStickyCell header>
|
||||
{loading ? (
|
||||
<SkeletonDot />
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected;
|
||||
}}
|
||||
onChange={toggleAll}
|
||||
className={TABLE_CHECKBOX_CLASS}
|
||||
/>
|
||||
)}
|
||||
<span>Name</span>
|
||||
</TableStickyCell>
|
||||
<TableHeaderCell className="ml-auto w-24">
|
||||
Columns
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="w-24">Documents</TableHeaderCell>
|
||||
<TableHeaderCell className="w-40">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Project</span>
|
||||
{projectFilterButton}
|
||||
</div>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="w-32">Created</TableHeaderCell>
|
||||
<TableHeaderCell className="w-8" />
|
||||
</TableHeaderRow>
|
||||
}
|
||||
>
|
||||
|
||||
{loading ? (
|
||||
<TableBody>
|
||||
|
|
@ -367,21 +384,8 @@ export default function TabularReviewsPage() {
|
|||
rightClickDropdown={(close) => (
|
||||
<RowActionMenuItems
|
||||
onClose={close}
|
||||
onRename={() => {
|
||||
if (
|
||||
user?.id &&
|
||||
review.user_id !== user.id
|
||||
) {
|
||||
setOwnerOnlyAction(
|
||||
"rename this tabular review",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setRenameValue(
|
||||
review.title ??
|
||||
"Untitled Review",
|
||||
);
|
||||
setRenamingId(review.id);
|
||||
onEditDetails={() => {
|
||||
requestReviewDetails(review);
|
||||
}}
|
||||
onDelete={async () => {
|
||||
if (
|
||||
|
|
@ -406,7 +410,6 @@ export default function TabularReviewsPage() {
|
|||
/>
|
||||
)}
|
||||
onClick={() => {
|
||||
if (renamingId === review.id) return;
|
||||
router.push(
|
||||
review.project_id
|
||||
? `/projects/${review.project_id}/tabular-reviews/${review.id}`
|
||||
|
|
@ -425,13 +428,6 @@ export default function TabularReviewsPage() {
|
|||
label={
|
||||
review.title ?? "Untitled Review"
|
||||
}
|
||||
editing={renamingId === review.id}
|
||||
editValue={renameValue}
|
||||
onEditValueChange={setRenameValue}
|
||||
onEditCommit={() =>
|
||||
handleRenameSubmit(review.id)
|
||||
}
|
||||
onEditCancel={() => setRenamingId(null)}
|
||||
/>
|
||||
<TableCell className="ml-auto w-24">
|
||||
{review.columns_config?.length ?? 0}
|
||||
|
|
@ -462,21 +458,8 @@ export default function TabularReviewsPage() {
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<RowActions
|
||||
onRename={() => {
|
||||
if (
|
||||
user?.id &&
|
||||
review.user_id !== user.id
|
||||
) {
|
||||
setOwnerOnlyAction(
|
||||
"rename this tabular review",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setRenameValue(
|
||||
review.title ??
|
||||
"Untitled Review",
|
||||
);
|
||||
setRenamingId(review.id);
|
||||
onEditDetails={() => {
|
||||
requestReviewDetails(review);
|
||||
}}
|
||||
onDelete={async () => {
|
||||
if (
|
||||
|
|
@ -507,14 +490,26 @@ export default function TabularReviewsPage() {
|
|||
)}
|
||||
</TableScrollArea>
|
||||
|
||||
<AddNewTRModal
|
||||
<NewTRModal
|
||||
open={newTROpen}
|
||||
onClose={() => setNewTROpen(false)}
|
||||
onAdd={handleNewReview}
|
||||
projects={projects}
|
||||
/>
|
||||
|
||||
<OwnerOnlyModal
|
||||
<TabularReviewDetailsModal
|
||||
open={!!detailsReview}
|
||||
review={detailsReview}
|
||||
projects={projects}
|
||||
canEdit={
|
||||
!!detailsReview &&
|
||||
(!user?.id || detailsReview.user_id === user.id)
|
||||
}
|
||||
onClose={() => setDetailsReview(null)}
|
||||
onSave={handleDetailsSave}
|
||||
/>
|
||||
|
||||
<OwnerOnlyPopup
|
||||
open={!!ownerOnlyAction}
|
||||
action={ownerOnlyAction ?? undefined}
|
||||
onClose={() => setOwnerOnlyAction(null)}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export function AddDocButton({
|
|||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.docx,.doc"
|
||||
accept=".pdf,.docx,.doc,.xlsx,.xlsm,.xls,.pptx,.ppt"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
|
|
|
|||
730
frontend/src/app/components/assistant/AskInputPopup.tsx
Normal file
730
frontend/src/app/components/assistant/AskInputPopup.tsx
Normal file
|
|
@ -0,0 +1,730 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
CornerDownLeft,
|
||||
Loader2,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AssistantEvent, Document } from "../shared/types";
|
||||
import { FileTypeIcon } from "../shared/FileTypeIcon";
|
||||
import {
|
||||
AddDocumentsModal,
|
||||
invalidateDirectoryCache,
|
||||
} from "../modals/AddDocumentsModal";
|
||||
import { uploadStandaloneDocument } from "@/app/lib/mikeApi";
|
||||
import {
|
||||
SUPPORTED_DOCUMENT_ACCEPT,
|
||||
formatUnsupportedDocumentWarning,
|
||||
partitionSupportedDocumentFiles,
|
||||
} from "@/app/lib/documentUploadValidation";
|
||||
|
||||
type AskInputsEvent = Extract<AssistantEvent, { type: "ask_inputs" }>;
|
||||
type AskInputItem = AskInputsEvent["items"][number];
|
||||
type AskInputsResponse = Extract<
|
||||
AssistantEvent,
|
||||
{ type: "ask_inputs_response" }
|
||||
>;
|
||||
|
||||
export function AskInputPopup({
|
||||
event,
|
||||
onSubmit,
|
||||
onDismiss,
|
||||
}: {
|
||||
event: AskInputsEvent;
|
||||
onSubmit?: (
|
||||
response: AskInputsResponse,
|
||||
content: string,
|
||||
files: { filename: string; document_id: string }[],
|
||||
) => void;
|
||||
onDismiss?: () => void;
|
||||
}) {
|
||||
const [choiceAnswers, setChoiceAnswers] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
const [otherOpen, setOtherOpen] = useState<Record<string, boolean>>({});
|
||||
const [otherValues, setOtherValues] = useState<Record<string, string>>({});
|
||||
const [docsByInput, setDocsByInput] = useState<Record<string, Document[]>>(
|
||||
{},
|
||||
);
|
||||
const [skipped, setSkipped] = useState<Set<string>>(() => new Set());
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [uploadingInputId, setUploadingInputId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [dragActiveInputId, setDragActiveInputId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
|
||||
const [docSelectorInputId, setDocSelectorInputId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [activeInputId, setActiveInputId] = useState(
|
||||
() => event.items[0]?.id ?? "",
|
||||
);
|
||||
const fileInputsRef = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
|
||||
const itemResolved = useCallback(
|
||||
(item: AskInputItem) => {
|
||||
if (skipped.has(item.id)) return true;
|
||||
if (item.kind === "choice") return !!choiceAnswers[item.id]?.trim();
|
||||
return (docsByInput[item.id] ?? []).length > 0;
|
||||
},
|
||||
[choiceAnswers, docsByInput, skipped],
|
||||
);
|
||||
|
||||
const firstUnresolvedId = useCallback(
|
||||
(resolvedId?: string) =>
|
||||
event.items.find((item) => {
|
||||
if (item.id === resolvedId) return false;
|
||||
return !itemResolved(item);
|
||||
})?.id ?? null,
|
||||
[event.items, itemResolved],
|
||||
);
|
||||
|
||||
const goToNextUnresolved = useCallback(
|
||||
(resolvedId: string) => {
|
||||
const nextId = firstUnresolvedId(resolvedId);
|
||||
if (nextId) setActiveInputId(nextId);
|
||||
},
|
||||
[firstUnresolvedId],
|
||||
);
|
||||
|
||||
const setSkippedFor = (id: string, shouldSkip = true) => {
|
||||
setSkipped((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (shouldSkip) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
if (shouldSkip) goToNextUnresolved(id);
|
||||
};
|
||||
|
||||
const addDocs = (inputId: string, selected: Document[]) => {
|
||||
if (selected.length === 0) return;
|
||||
setSkippedFor(inputId, false);
|
||||
setDocsByInput((prev) => {
|
||||
const current = prev[inputId] ?? [];
|
||||
const existing = new Set(current.map((doc) => doc.id));
|
||||
return {
|
||||
...prev,
|
||||
[inputId]: [
|
||||
...current,
|
||||
...selected.filter((doc) => !existing.has(doc.id)),
|
||||
],
|
||||
};
|
||||
});
|
||||
goToNextUnresolved(inputId);
|
||||
};
|
||||
|
||||
const removeDoc = (inputId: string, docId: string) => {
|
||||
setDocsByInput((prev) => ({
|
||||
...prev,
|
||||
[inputId]: (prev[inputId] ?? []).filter((doc) => doc.id !== docId),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleFiles = async (inputId: string, incomingFiles: File[]) => {
|
||||
if (!incomingFiles.length || submitted) return;
|
||||
const { supported, unsupported } =
|
||||
partitionSupportedDocumentFiles(incomingFiles);
|
||||
setUploadWarning(formatUnsupportedDocumentWarning(unsupported));
|
||||
if (supported.length === 0) {
|
||||
const input = fileInputsRef.current[inputId];
|
||||
if (input) input.value = "";
|
||||
return;
|
||||
}
|
||||
setUploadingInputId(inputId);
|
||||
try {
|
||||
const uploaded = await Promise.all(
|
||||
supported.map((file) => uploadStandaloneDocument(file)),
|
||||
);
|
||||
invalidateDirectoryCache();
|
||||
addDocs(inputId, uploaded);
|
||||
} catch (err) {
|
||||
console.error("Document upload failed:", err);
|
||||
} finally {
|
||||
setUploadingInputId(null);
|
||||
setDragActiveInputId(null);
|
||||
const input = fileInputsRef.current[inputId];
|
||||
if (input) input.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const chooseAnswer = (
|
||||
item: Extract<AskInputItem, { kind: "choice" }>,
|
||||
answer: string,
|
||||
) => {
|
||||
const trimmed = answer.trim();
|
||||
if (!trimmed || submitted) return;
|
||||
setSkippedFor(item.id, false);
|
||||
setChoiceAnswers((prev) => ({ ...prev, [item.id]: trimmed }));
|
||||
goToNextUnresolved(item.id);
|
||||
};
|
||||
|
||||
const allResolved =
|
||||
event.items.length > 0 && event.items.every(itemResolved);
|
||||
const canSubmit =
|
||||
!submitted &&
|
||||
!uploadingInputId &&
|
||||
allResolved &&
|
||||
!!onSubmit;
|
||||
|
||||
const buildResponse = (): AskInputsResponse => {
|
||||
const responses = event.items.map((item) => {
|
||||
if (skipped.has(item.id)) {
|
||||
return item.kind === "choice"
|
||||
? {
|
||||
id: item.id,
|
||||
kind: "choice" as const,
|
||||
question: item.question,
|
||||
skipped: true,
|
||||
}
|
||||
: {
|
||||
id: item.id,
|
||||
kind: "documents" as const,
|
||||
filenames: [],
|
||||
skipped: true,
|
||||
};
|
||||
}
|
||||
if (item.kind === "choice") {
|
||||
return {
|
||||
id: item.id,
|
||||
kind: "choice" as const,
|
||||
question: item.question,
|
||||
answer: choiceAnswers[item.id]?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: item.id,
|
||||
kind: "documents" as const,
|
||||
filenames: (docsByInput[item.id] ?? []).map(
|
||||
(doc) => doc.filename,
|
||||
),
|
||||
};
|
||||
});
|
||||
return { type: "ask_inputs_response", responses };
|
||||
};
|
||||
|
||||
const responseFiles = (response: AskInputsResponse) => {
|
||||
const responseById = new Map(response.responses.map((item) => [item.id, item]));
|
||||
const docs = event.items.flatMap((item) => {
|
||||
const responseItem = responseById.get(item.id);
|
||||
if (
|
||||
item.kind !== "documents" ||
|
||||
responseItem?.kind !== "documents" ||
|
||||
responseItem.skipped
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return docsByInput[item.id] ?? [];
|
||||
});
|
||||
const seen = new Set<string>();
|
||||
return docs.flatMap((doc) => {
|
||||
if (seen.has(doc.id)) return [];
|
||||
seen.add(doc.id);
|
||||
return [{ filename: doc.filename, document_id: doc.id }];
|
||||
});
|
||||
};
|
||||
|
||||
const buildContent = (response: AskInputsResponse) => {
|
||||
const lines = response.responses.map((item, index) => {
|
||||
if (item.kind === "choice") {
|
||||
if (item.skipped)
|
||||
return `${index + 1}. Skipped: ${item.question}`;
|
||||
return `${index + 1}. ${item.question}\n${item.answer ?? ""}`;
|
||||
}
|
||||
if (item.skipped) return `${index + 1}. Skipped document request.`;
|
||||
return `${index + 1}. Documents attached: ${item.filenames.join(", ")}`;
|
||||
});
|
||||
return `Responses to Mike's questions:\n${lines.join("\n\n")}`;
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (!canSubmit) return;
|
||||
const response = buildResponse();
|
||||
setSubmitted(true);
|
||||
onSubmit?.(response, buildContent(response), responseFiles(response));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (canSubmit) submit();
|
||||
});
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
if (submitted || dismissed) return;
|
||||
setDismissed(true);
|
||||
onDismiss?.();
|
||||
}, [dismissed, onDismiss, submitted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (submitted || dismissed) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") dismiss();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [submitted, dismissed, dismiss]);
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
const activeItem =
|
||||
event.items.find((item) => item.id === activeInputId) ?? event.items[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full overflow-hidden rounded-[18px] border border-white/65 bg-white/60 pb-3 font-serif shadow-[0_4px_10px_rgba(15,23,42,0.084),inset_0_1px_0_rgba(255,255,255,0.595),inset_0_-6px_14px_rgba(255,255,255,0.126)] backdrop-blur-2xl md:rounded-[22px]">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 bg-gray-100/70 px-3 py-2">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<div className="text-sm text-gray-500">
|
||||
{submitted ? (
|
||||
"Inputs sent"
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{event.items.map((item, index) => {
|
||||
const isActive =
|
||||
item.id === activeItem?.id;
|
||||
const isResolved = itemResolved(item);
|
||||
const label =
|
||||
item.kind === "choice"
|
||||
? `Question ${index + 1}`
|
||||
: "Add Documents";
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
disabled={submitted}
|
||||
onClick={() =>
|
||||
setActiveInputId(item.id)
|
||||
}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full py-0.5 font-sans text-[10px] transition-colors disabled:cursor-default",
|
||||
isActive
|
||||
? "text-gray-900"
|
||||
: "text-gray-400 hover:text-gray-700",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{isResolved ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<span className="h-2.5 w-2.5 rounded-full border border-current opacity-70" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!submitted && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="shrink-0 rounded-full py-0.5 font-sans text-[10px] text-gray-500 transition-colors hover:text-gray-700"
|
||||
>
|
||||
Esc (end response)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-3">
|
||||
{activeItem && (
|
||||
<div className="mt-3 flex min-h-54 flex-col">
|
||||
<div className="mt-auto">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{activeItem.kind === "choice" ? (
|
||||
<p className="text-sm text-gray-800">
|
||||
{activeItem.question}
|
||||
</p>
|
||||
) : (
|
||||
<DocumentPrompt item={activeItem} />
|
||||
)}
|
||||
</div>
|
||||
{!submitted && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSkippedFor(
|
||||
activeItem.id,
|
||||
!skipped.has(activeItem.id),
|
||||
)
|
||||
}
|
||||
className="shrink-0 rounded-full py-0.5 font-sans text-[10px] text-gray-500 transition-colors hover:text-gray-800 disabled:cursor-default disabled:opacity-40"
|
||||
>
|
||||
{skipped.has(activeItem.id)
|
||||
? "Unskip"
|
||||
: "Skip"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-3">
|
||||
{activeItem.kind === "choice" ? (
|
||||
<OptionInput
|
||||
item={activeItem}
|
||||
disabled={
|
||||
submitted ||
|
||||
skipped.has(activeItem.id)
|
||||
}
|
||||
selectedAnswer={
|
||||
choiceAnswers[activeItem.id] ??
|
||||
null
|
||||
}
|
||||
otherOpen={
|
||||
!!otherOpen[activeItem.id]
|
||||
}
|
||||
otherValue={
|
||||
otherValues[activeItem.id] ?? ""
|
||||
}
|
||||
onAnswer={(answer) =>
|
||||
chooseAnswer(activeItem, answer)
|
||||
}
|
||||
onOtherOpen={() =>
|
||||
setOtherOpen((prev) => ({
|
||||
...prev,
|
||||
[activeItem.id]: true,
|
||||
}))
|
||||
}
|
||||
onOtherValue={(value) =>
|
||||
setOtherValues((prev) => ({
|
||||
...prev,
|
||||
[activeItem.id]: value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DocumentInput
|
||||
item={activeItem}
|
||||
disabled={
|
||||
submitted ||
|
||||
skipped.has(activeItem.id)
|
||||
}
|
||||
docs={
|
||||
docsByInput[activeItem.id] ?? []
|
||||
}
|
||||
uploading={
|
||||
uploadingInputId ===
|
||||
activeItem.id
|
||||
}
|
||||
dragActive={
|
||||
dragActiveInputId ===
|
||||
activeItem.id
|
||||
}
|
||||
fileInputRef={(node) => {
|
||||
fileInputsRef.current[
|
||||
activeItem.id
|
||||
] = node;
|
||||
}}
|
||||
onFiles={(files) =>
|
||||
void handleFiles(
|
||||
activeItem.id,
|
||||
files,
|
||||
)
|
||||
}
|
||||
onDragActive={(active) =>
|
||||
setDragActiveInputId(
|
||||
active
|
||||
? activeItem.id
|
||||
: null,
|
||||
)
|
||||
}
|
||||
onBrowse={() =>
|
||||
setDocSelectorInputId(
|
||||
activeItem.id,
|
||||
)
|
||||
}
|
||||
onRemoveDoc={(docId) =>
|
||||
removeDoc(activeItem.id, docId)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadWarning && (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-lg border border-red-100 bg-red-50 px-3 py-2 font-sans 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>
|
||||
</div>
|
||||
|
||||
<AddDocumentsModal
|
||||
open={!!docSelectorInputId}
|
||||
onClose={() => setDocSelectorInputId(null)}
|
||||
onSelect={(selected) => {
|
||||
if (docSelectorInputId)
|
||||
addDocs(docSelectorInputId, selected);
|
||||
}}
|
||||
breadcrumb={["Assistant", "Add Documents"]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionInput({
|
||||
item,
|
||||
disabled,
|
||||
selectedAnswer,
|
||||
otherOpen,
|
||||
otherValue,
|
||||
onAnswer,
|
||||
onOtherOpen,
|
||||
onOtherValue,
|
||||
}: {
|
||||
item: Extract<AskInputItem, { kind: "choice" }>;
|
||||
disabled?: boolean;
|
||||
selectedAnswer: string | null;
|
||||
otherOpen: boolean;
|
||||
otherValue: string;
|
||||
onAnswer: (answer: string) => void;
|
||||
onOtherOpen: () => void;
|
||||
onOtherValue: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-2 grid gap-2">
|
||||
{item.options.map((option, idx) => {
|
||||
const answer = option.value.trim();
|
||||
const isSelected = selectedAnswer === answer.trim();
|
||||
return (
|
||||
<button
|
||||
key={`${item.id}-${option.value}-${idx}`}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onAnswer(answer)}
|
||||
className={`w-full rounded-lg p-2 text-left transition-colors ${
|
||||
isSelected
|
||||
? "bg-gray-200/80 text-gray-900"
|
||||
: "bg-gray-100/70 text-gray-700 hover:bg-gray-200/70 disabled:hover:bg-gray-100/70"
|
||||
} disabled:cursor-default disabled:opacity-60`}
|
||||
>
|
||||
<span className="flex items-start gap-1.5">
|
||||
<span className="mt-0.5 w-4 shrink-0 text-xs text-gray-500">
|
||||
{idx + 1}.
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm">{answer}</span>
|
||||
</span>
|
||||
{isSelected && (
|
||||
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-gray-700" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{item.allow_other && (
|
||||
<div
|
||||
className={`w-full rounded-lg p-2 transition-colors ${
|
||||
otherOpen
|
||||
? "bg-gray-200/80"
|
||||
: "cursor-pointer bg-gray-100/70 hover:bg-gray-200/70"
|
||||
} ${disabled ? "cursor-default opacity-60" : ""}`}
|
||||
onClick={() => !otherOpen && !disabled && onOtherOpen()}
|
||||
>
|
||||
<span className="flex items-start gap-1.5">
|
||||
<span className="mt-0.5 w-4 shrink-0 text-xs text-gray-500">
|
||||
{item.options.length + 1}.
|
||||
</span>
|
||||
{otherOpen ? (
|
||||
<span className="min-w-0 flex-1 flex items-end gap-2">
|
||||
<textarea
|
||||
name={`other-${item.id}`}
|
||||
rows={1}
|
||||
autoFocus
|
||||
value={otherValue}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
onOtherValue(e.target.value);
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onAnswer(otherValue);
|
||||
}
|
||||
}}
|
||||
placeholder="Type your answer..."
|
||||
className="flex-1 resize-none overflow-hidden bg-transparent text-sm leading-5 text-gray-600 outline-none placeholder:text-gray-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || !otherValue.trim()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAnswer(otherValue);
|
||||
}}
|
||||
className="shrink-0 flex items-center gap-1 rounded-full bg-blue-600 px-3 py-0.5 font-sans text-[10px] text-white transition-colors hover:bg-blue-700 disabled:cursor-default disabled:opacity-40"
|
||||
>
|
||||
Set
|
||||
<CornerDownLeft className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 text-sm text-gray-700">
|
||||
{item.other_label || "Other"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentPrompt({
|
||||
item,
|
||||
}: {
|
||||
item: Extract<AskInputItem, { kind: "documents" }>;
|
||||
}) {
|
||||
const documentTypes = item.document_types ?? [];
|
||||
return (
|
||||
<div className="mt-0.5 text-sm text-gray-800">
|
||||
<p>Add the following documents if available:</p>
|
||||
{documentTypes.length > 0 && (
|
||||
<div className="mt-1 space-y-0.5 text-gray-700">
|
||||
{documentTypes.map((documentType, index) => (
|
||||
<p
|
||||
key={`${documentType}-${index}`}
|
||||
className="break-words"
|
||||
>
|
||||
{index + 1}. {documentType}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentInput({
|
||||
item,
|
||||
disabled,
|
||||
docs,
|
||||
uploading,
|
||||
dragActive,
|
||||
fileInputRef,
|
||||
onFiles,
|
||||
onDragActive,
|
||||
onBrowse,
|
||||
onRemoveDoc,
|
||||
}: {
|
||||
item: Extract<AskInputItem, { kind: "documents" }>;
|
||||
disabled?: boolean;
|
||||
docs: Document[];
|
||||
uploading: boolean;
|
||||
dragActive: boolean;
|
||||
fileInputRef: (node: HTMLInputElement | null) => void;
|
||||
onFiles: (files: File[]) => void;
|
||||
onDragActive: (active: boolean) => void;
|
||||
onBrowse: () => void;
|
||||
onRemoveDoc: (docId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={SUPPORTED_DOCUMENT_ACCEPT}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => onFiles(Array.from(e.target.files || []))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || uploading}
|
||||
onClick={onBrowse}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled && !uploading) onDragActive(true);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled && !uploading) onDragActive(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDragActive(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDragActive(false);
|
||||
onFiles(Array.from(e.dataTransfer.files || []));
|
||||
}}
|
||||
className={`flex h-[168px] w-full flex-col items-center justify-center gap-1.5 rounded-lg px-3 py-4 font-sans text-xs transition-colors disabled:cursor-default disabled:opacity-50 ${
|
||||
dragActive
|
||||
? "bg-gray-300 text-gray-900"
|
||||
: "bg-gray-100/80 text-gray-500 hover:bg-gray-200/80 hover:text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-gray-800">
|
||||
{uploading
|
||||
? "Uploading documents..."
|
||||
: "Drop files here or click to choose documents"}
|
||||
</span>
|
||||
<span className="text-[11px] text-gray-400">
|
||||
PDF, Word, Excel, or PowerPoint
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{docs.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{docs.map((doc) => {
|
||||
return (
|
||||
<div
|
||||
key={`${item.id}-${doc.id}`}
|
||||
className="inline-flex items-center gap-1 rounded-[10px] border border-white/70 bg-white py-0.5 pl-2 pr-1 text-xs text-gray-800 shadow-[0_2px_6px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9)] backdrop-blur-xl"
|
||||
>
|
||||
<FileTypeIcon
|
||||
fileType={doc.file_type}
|
||||
className="h-2.5 w-2.5"
|
||||
/>
|
||||
<span className="max-w-[160px] truncate">
|
||||
{doc.filename}
|
||||
</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveDoc(doc.id)}
|
||||
className="ml-0.5 rounded-full p-0.5 text-gray-400 transition-colors hover:bg-gray-900/5 hover:text-gray-700"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -8,9 +8,9 @@ import {
|
|||
type CSSProperties,
|
||||
} from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { DocPanel, type DocPanelMode } from "../shared/DocPanel";
|
||||
import { DocPanel, type DocPanelMode } from "./DocPanel";
|
||||
import type {
|
||||
CitationAnnotation,
|
||||
Citation,
|
||||
EditAnnotation,
|
||||
} from "../shared/types";
|
||||
import {
|
||||
|
|
@ -45,12 +45,13 @@ export type DocumentTab = CommonTab & { kind: "document" };
|
|||
|
||||
export type CitationTab = CommonTab & {
|
||||
kind: "citation";
|
||||
citation: CitationAnnotation;
|
||||
citation: Citation;
|
||||
};
|
||||
|
||||
export type EditTab = CommonTab & {
|
||||
kind: "edit";
|
||||
edit: EditAnnotation;
|
||||
changeNumber?: number;
|
||||
};
|
||||
|
||||
export type AssistantSidePanelTab =
|
||||
|
|
@ -307,6 +308,7 @@ export function AssistantSidePanel({
|
|||
? {
|
||||
kind: "edit",
|
||||
edit: tab.edit,
|
||||
changeNumber: tab.changeNumber,
|
||||
isEditReloading:
|
||||
isEditReloading?.(tab.edit.edit_id) ??
|
||||
false,
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ import type { CaseCitationQuote } from "../shared/types";
|
|||
import {
|
||||
clearDocxQuoteHighlights,
|
||||
highlightDocxQuote,
|
||||
} from "../shared/highlightDocxQuote";
|
||||
} from "../shared/views/highlightDocxQuote";
|
||||
import {
|
||||
RelevantQuotes,
|
||||
type RelevantQuoteItem,
|
||||
} from "../shared/RelevantQuotes";
|
||||
CitationQuotesHeader,
|
||||
type CitationQuoteHeaderItem,
|
||||
} from "./CitationQuotesHeader";
|
||||
import {
|
||||
getCourtlistenerOpinions,
|
||||
type CaseLawOpinion,
|
||||
|
|
@ -292,7 +292,7 @@ export function CaseLawPanel({
|
|||
Math.max(relevantQuotes.length - 1, 0),
|
||||
)
|
||||
: 0;
|
||||
const relevantQuoteItems: RelevantQuoteItem[] = relevantQuotes.map(
|
||||
const relevantQuoteItems: CitationQuoteHeaderItem[] = relevantQuotes.map(
|
||||
(quote, index) => ({
|
||||
id: relevantQuoteKey(quote, index),
|
||||
quote: quote.quote,
|
||||
|
|
@ -432,7 +432,7 @@ export function CaseLawPanel({
|
|||
</div>
|
||||
</div>
|
||||
{relevantQuoteItems.length > 0 && (
|
||||
<RelevantQuotes
|
||||
<CitationQuotesHeader
|
||||
quotes={relevantQuoteItems}
|
||||
activeQuoteId={activeQuoteKey}
|
||||
currentIndex={currentQuoteIndex}
|
||||
|
|
|
|||
|
|
@ -11,17 +11,16 @@ import {
|
|||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
File,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Library,
|
||||
Square,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { AddDocButton } from "./AddDocButton";
|
||||
import { AddDocumentsModal } from "../shared/AddDocumentsModal";
|
||||
import { FileTypeIcon } from "../shared/FileTypeIcon";
|
||||
import { AddDocumentsModal } from "../modals/AddDocumentsModal";
|
||||
import { AssistantWorkflowModal } from "./AssistantWorkflowModal";
|
||||
import { ApiKeyMissingModal } from "../shared/ApiKeyMissingModal";
|
||||
import { ApiKeyMissingPopup } from "../popups/ApiKeyMissingPopup";
|
||||
import { ModelToggle } from "./ModelToggle";
|
||||
import { useSelectedModel } from "@/app/hooks/useSelectedModel";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
|
|
@ -193,18 +192,15 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
</div>
|
||||
)}
|
||||
{attachedDocs.map((doc) => {
|
||||
const ft = doc.file_type?.toLowerCase();
|
||||
const isPdf = ft === "pdf";
|
||||
return (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="inline-flex items-center gap-1 rounded-[10px] border border-white/70 bg-white py-0.5 pl-2 pr-1 text-xs text-gray-800 shadow-[0_2px_6px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9)] backdrop-blur-xl"
|
||||
>
|
||||
{isPdf ? (
|
||||
<FileText className="h-2.5 w-2.5 shrink-0 text-red-500" />
|
||||
) : (
|
||||
<File className="h-2.5 w-2.5 shrink-0 text-blue-500" />
|
||||
)}
|
||||
<FileTypeIcon
|
||||
fileType={doc.file_type}
|
||||
className="h-2.5 w-2.5"
|
||||
/>
|
||||
<span className="max-w-[140px] truncate">
|
||||
{doc.filename}
|
||||
</span>
|
||||
|
|
@ -348,7 +344,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
projectName={projectName}
|
||||
projectCmNumber={projectCmNumber}
|
||||
/>
|
||||
<ApiKeyMissingModal
|
||||
<ApiKeyMissingPopup
|
||||
open={apiKeyModalProvider !== null}
|
||||
provider={apiKeyModalProvider}
|
||||
onClose={() => setApiKeyModalProvider(null)}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { ArrowDown } from "lucide-react";
|
|||
import { UserMessage } from "./UserMessage";
|
||||
import { AssistantMessage } from "./AssistantMessage";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import type { ChatInputHandle } from "./ChatInput";
|
||||
import { AskInputPopup } from "./AskInputPopup";
|
||||
import {
|
||||
AssistantSidePanel,
|
||||
type AssistantSidePanelTab,
|
||||
|
|
@ -13,24 +15,35 @@ import {
|
|||
import { AssistantWorkflowModal } from "./AssistantWorkflowModal";
|
||||
import type {
|
||||
AssistantEvent,
|
||||
CitationAnnotation,
|
||||
Citation,
|
||||
EditAnnotation,
|
||||
Message,
|
||||
} from "../shared/types";
|
||||
import { useSidebar } from "@/app/contexts/SidebarContext";
|
||||
import { invalidateDocxBytes } from "@/app/hooks/useFetchDocxBytes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
chatId?: string | null;
|
||||
messages: Message[];
|
||||
isResponseLoading: boolean;
|
||||
handleChat: (message: Message) => Promise<string | null>;
|
||||
handleChat: (
|
||||
message: Message,
|
||||
opts?: {
|
||||
displayedDoc?: { filename: string; documentId: string } | null;
|
||||
askInputsResponse?: Extract<
|
||||
AssistantEvent,
|
||||
{ type: "ask_inputs_response" }
|
||||
>;
|
||||
},
|
||||
) => Promise<string | null>;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
const ASSISTANT_PANEL_TRANSITION_MS = 500;
|
||||
const MOBILE_BREAKPOINT_PX = 768;
|
||||
const DEFAULT_ASSISTANT_BOTTOM_PADDING = 116;
|
||||
const SCROLL_BUTTON_INPUT_GAP = 16;
|
||||
const CHAT_INPUT_BOTTOM_OFFSET = 12;
|
||||
|
||||
function isSmallScreen() {
|
||||
return (
|
||||
|
|
@ -54,6 +67,9 @@ export function ChatView({
|
|||
const [workflowModalInitialId, setWorkflowModalInitialId] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [hiddenAskInputKeys, setHiddenAskInputKeys] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [reloadingDocIds, setReloadingDocIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
|
@ -66,6 +82,10 @@ export function ChatView({
|
|||
const { setSidebarOpen } = useSidebar();
|
||||
const panelCloseTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setHiddenAskInputKeys(new Set());
|
||||
}, [chatId]);
|
||||
|
||||
const showPanel = useCallback(() => {
|
||||
if (panelCloseTimerRef.current !== null) {
|
||||
window.clearTimeout(panelCloseTimerRef.current);
|
||||
|
|
@ -199,7 +219,7 @@ export function ChatView({
|
|||
* AssistantMessage when the user clicks a numbered citation pill.
|
||||
*/
|
||||
const openCitation = useCallback(
|
||||
(citation: CitationAnnotation, options?: { showQuotes?: boolean }) => {
|
||||
(citation: Citation, options?: { showQuotes?: boolean }) => {
|
||||
const showQuotes = options?.showQuotes ?? true;
|
||||
if (citation.kind === "case") {
|
||||
if (!chatId) return;
|
||||
|
|
@ -270,7 +290,7 @@ export function ChatView({
|
|||
* AssistantMessage when the user clicks an EditCard's View button.
|
||||
*/
|
||||
const openEditor = useCallback(
|
||||
(ann: EditAnnotation, filename: string) => {
|
||||
(ann: EditAnnotation, filename: string, changeNumber?: number) => {
|
||||
upsertTab({
|
||||
kind: "edit",
|
||||
id: ann.document_id,
|
||||
|
|
@ -279,6 +299,7 @@ export function ChatView({
|
|||
versionId: ann.version_id ?? null,
|
||||
versionNumber: ann.version_number ?? null,
|
||||
edit: ann,
|
||||
changeNumber,
|
||||
});
|
||||
},
|
||||
[upsertTab],
|
||||
|
|
@ -450,7 +471,8 @@ export function ChatView({
|
|||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const latestUserMessageRef = useRef<HTMLDivElement>(null);
|
||||
const chatInputRef = useRef<HTMLDivElement>(null);
|
||||
const chatInputRef = useRef<ChatInputHandle | null>(null);
|
||||
const measuredInputRef = useRef<HTMLDivElement>(null);
|
||||
// Seed "already in place" when messages exist at mount (a freshly created
|
||||
// chat arrives with its first message in hand). Otherwise the skeleton +
|
||||
// opacity-0 gate would flash the message out and fade it back in on every
|
||||
|
|
@ -465,28 +487,26 @@ export function ChatView({
|
|||
const [minHeight, setMinHeight] = useState("0px");
|
||||
|
||||
useEffect(() => {
|
||||
const el = chatInputRef.current;
|
||||
const el = measuredInputRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver(() =>
|
||||
setInputHeight(el.offsetHeight),
|
||||
);
|
||||
const update = () => setInputHeight(el.offsetHeight);
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(el);
|
||||
setInputHeight(el.offsetHeight);
|
||||
update();
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (latestUserMessageRef.current) {
|
||||
const headerHeight = window.innerWidth < 768 ? 56 : 0;
|
||||
const gap = window.innerWidth < 768 ? 16 : 24;
|
||||
const paddingBottom = 128;
|
||||
const marginBottom = 48;
|
||||
const messageGap = window.innerWidth < 768 ? 24 : 32;
|
||||
const paddingBottom = DEFAULT_ASSISTANT_BOTTOM_PADDING;
|
||||
const userMessageHeight = latestUserMessageRef.current.offsetHeight;
|
||||
setMinHeight(
|
||||
`calc(100dvh - ${headerHeight + gap + userMessageHeight + paddingBottom + marginBottom}px)`,
|
||||
`calc(100dvh - ${headerHeight + messageGap * 3 + userMessageHeight + paddingBottom}px)`,
|
||||
);
|
||||
}
|
||||
}, [messages.length, latestUserMessageRef.current]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [messages.length]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const updateScrollButton = useCallback(() => {
|
||||
const c = messagesContainerRef.current;
|
||||
|
|
@ -573,6 +593,41 @@ export function ChatView({
|
|||
};
|
||||
}, [panelMounted]);
|
||||
|
||||
const rawActiveInput = (() => {
|
||||
for (
|
||||
let messageIndex = messages.length - 1;
|
||||
messageIndex >= 0;
|
||||
messageIndex--
|
||||
) {
|
||||
const message = messages[messageIndex];
|
||||
if (message.role === "user") return null;
|
||||
if (message.role !== "assistant" || !message.events) continue;
|
||||
for (
|
||||
let eventIndex = message.events.length - 1;
|
||||
eventIndex >= 0;
|
||||
eventIndex--
|
||||
) {
|
||||
const event = message.events[eventIndex];
|
||||
if (event.type === "ask_inputs_response") {
|
||||
return null;
|
||||
}
|
||||
if (event.type === "ask_inputs") {
|
||||
return {
|
||||
key: `${messageIndex}-${eventIndex}`,
|
||||
event,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const activeInput =
|
||||
rawActiveInput && !hiddenAskInputKeys.has(rawActiveInput.key)
|
||||
? rawActiveInput
|
||||
: null;
|
||||
|
||||
const messagesBottomPadding = DEFAULT_ASSISTANT_BOTTOM_PADDING;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex relative">
|
||||
{/* Chat column */}
|
||||
|
|
@ -583,9 +638,12 @@ export function ChatView({
|
|||
className="flex-1 w-full overflow-y-auto"
|
||||
style={{ scrollbarGutter: "stable both-edges" }}
|
||||
>
|
||||
<div className="w-full max-w-4xl mx-auto pb-32 px-6 md:px-8 pt-4 md:pt-6 min-h-full flex flex-col relative">
|
||||
<div
|
||||
className="w-full max-w-4xl mx-auto px-6 pt-6 md:px-8 md:pt-8 min-h-full flex flex-col relative"
|
||||
style={{ paddingBottom: messagesBottomPadding }}
|
||||
>
|
||||
{!messagesVisible && (
|
||||
<div className="space-y-6 w-full">
|
||||
<div className="space-y-6 md:space-y-8 w-full">
|
||||
<div className="flex justify-end">
|
||||
<div className="bg-gray-100 rounded-2xl p-4 w-2/5">
|
||||
<div className="h-4 bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 bg-[length:200%_100%] animate-[shimmer_2s_ease-in-out_infinite] rounded w-full" />
|
||||
|
|
@ -602,7 +660,7 @@ export function ChatView({
|
|||
</div>
|
||||
)}
|
||||
<div
|
||||
className="space-y-6 transition-opacity duration-150"
|
||||
className="space-y-6 md:space-y-8 transition-opacity duration-150"
|
||||
style={{ opacity: messagesVisible ? 1 : 0 }}
|
||||
>
|
||||
{(() => {
|
||||
|
|
@ -629,7 +687,6 @@ export function ChatView({
|
|||
/>
|
||||
) : (
|
||||
<AssistantMessage
|
||||
content={msg.content ?? ""}
|
||||
events={msg.events}
|
||||
isStreaming={
|
||||
i === messages.length - 1 &&
|
||||
|
|
@ -642,7 +699,7 @@ export function ChatView({
|
|||
? (msg as any).error
|
||||
: undefined
|
||||
}
|
||||
annotations={msg.annotations}
|
||||
citations={msg.citations}
|
||||
citationStatus={
|
||||
msg.citationStatus
|
||||
}
|
||||
|
|
@ -702,14 +759,16 @@ export function ChatView({
|
|||
{showScrollButton && (
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2 z-19"
|
||||
style={{ bottom: inputHeight + 12 }}
|
||||
style={{
|
||||
bottom:
|
||||
inputHeight +
|
||||
CHAT_INPUT_BOTTOM_OFFSET +
|
||||
SCROLL_BUTTON_INPUT_GAP,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
className={cn(
|
||||
"rounded-full p-2 cursor-pointer transition-all",
|
||||
"bg-white/30 shadow-[0_5px_16px_rgba(15,23,42,0.13),inset_0_1px_0_rgba(255,255,255,0.75),inset_0_-8px_18px_rgba(255,255,255,0.26)] backdrop-blur-xl hover:bg-white/45 hover:shadow-[0_7px_20px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.85),inset_0_-8px_18px_rgba(255,255,255,0.32)]",
|
||||
)}
|
||||
className="rounded-full p-2 cursor-pointer transition-all bg-white/30 shadow-[0_5px_16px_rgba(15,23,42,0.13),inset_0_1px_0_rgba(255,255,255,0.75),inset_0_-8px_18px_rgba(255,255,255,0.26)] backdrop-blur-xl hover:bg-white/45 hover:shadow-[0_7px_20px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.85),inset_0_-8px_18px_rgba(255,255,255,0.32)]"
|
||||
>
|
||||
<ArrowDown className="h-6 w-6 text-gray-500" />
|
||||
</button>
|
||||
|
|
@ -717,34 +776,51 @@ export function ChatView({
|
|||
)}
|
||||
|
||||
{/* Chat input */}
|
||||
<div
|
||||
ref={chatInputRef}
|
||||
className="absolute bottom-0 left-0 right-0 w-full z-30"
|
||||
>
|
||||
<div className="absolute bottom-3 left-0 right-0 w-full z-30">
|
||||
<div className="pointer-events-none absolute -bottom-3 left-0 right-0 z-0">
|
||||
<div className="mx-auto h-7 w-full max-w-4xl px-4 md:px-6">
|
||||
<div className="h-full rounded-t-[20px] bg-white/50 backdrop-blur-[1px]" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-0 left-0 z-0",
|
||||
"right-4 h-28 bg-gradient-to-t from-white/50 via-white/25 to-transparent backdrop-blur-[1px]",
|
||||
)}
|
||||
/>
|
||||
<div className="relative z-20 w-full max-w-4xl mx-auto px-4 md:px-6">
|
||||
<div
|
||||
className={cn(
|
||||
"w-full rounded-t-[20px]",
|
||||
"bg-transparent",
|
||||
ref={measuredInputRef}
|
||||
className="relative z-20 w-full max-w-4xl mx-auto px-4 md:px-6"
|
||||
>
|
||||
<div className="w-full rounded-t-[20px] bg-transparent">
|
||||
{activeInput ? (
|
||||
<AskInputPopup
|
||||
key={activeInput.key}
|
||||
event={activeInput.event}
|
||||
onSubmit={(response, content, files) => {
|
||||
setHiddenAskInputKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(activeInput.key);
|
||||
return next;
|
||||
});
|
||||
void handleChat(
|
||||
{ role: "user", content, files },
|
||||
{
|
||||
askInputsResponse: response,
|
||||
},
|
||||
);
|
||||
}}
|
||||
onDismiss={() => {
|
||||
setHiddenAskInputKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(activeInput.key);
|
||||
return next;
|
||||
});
|
||||
cancel();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ChatInput
|
||||
ref={chatInputRef}
|
||||
onSubmit={handleChat}
|
||||
onCancel={cancel}
|
||||
isLoading={isResponseLoading}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<ChatInput
|
||||
onSubmit={handleChat}
|
||||
onCancel={cancel}
|
||||
isLoading={isResponseLoading}
|
||||
/>
|
||||
<div className="py-3 text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
AI can make mistakes. Answers are not legal
|
||||
advice.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { PillButton } from "@/components/ui/pill-button";
|
||||
import type { EditAnnotation } from "../shared/types";
|
||||
|
||||
function normalizeText(s: string) {
|
||||
|
|
@ -131,6 +132,7 @@ export function applyOptimisticResolution(
|
|||
|
||||
interface Props {
|
||||
annotation: EditAnnotation;
|
||||
changeNumber?: number;
|
||||
/**
|
||||
* External override for this edit's status. When set, takes
|
||||
* precedence over the annotation's DB status and the card's own
|
||||
|
|
@ -183,6 +185,7 @@ interface Props {
|
|||
*/
|
||||
export function EditCard({
|
||||
annotation,
|
||||
changeNumber,
|
||||
resolvedStatus,
|
||||
isReloading,
|
||||
onViewClick,
|
||||
|
|
@ -275,13 +278,16 @@ export function EditCard({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-3 bg-gray-50">
|
||||
<div className="rounded-xl bg-white shadow-[0_3px_9px_rgba(15,23,42,0.1),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.05)] backdrop-blur-2xl p-3">
|
||||
{changeNumber !== undefined && (
|
||||
<p className="text-xs text-gray-400 mb-1.5">{changeNumber}</p>
|
||||
)}
|
||||
{annotation.reason && (
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
{annotation.reason}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-sm leading-relaxed font-serif bg-white border border-gray-200 rounded-md px-2 py-2">
|
||||
<div className="text-sm leading-relaxed font-serif bg-gray-100/70 rounded-lg px-2 py-2">
|
||||
{annotation.inserted_text && (
|
||||
<span className="text-green-700">
|
||||
{annotation.inserted_text}
|
||||
|
|
@ -294,22 +300,26 @@ export function EditCard({
|
|||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
<PillButton
|
||||
tone="black"
|
||||
size="sm"
|
||||
onClick={() => handle("accept")}
|
||||
disabled={inFlight || resolved}
|
||||
className="px-2 py-1 text-xs rounded border border-gray-900 bg-gray-900 text-white hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
{status === "accepted" ? "Accepted" : "Accept"}
|
||||
</button>
|
||||
<button
|
||||
</PillButton>
|
||||
<PillButton
|
||||
tone="white"
|
||||
size="sm"
|
||||
onClick={() => handle("reject")}
|
||||
disabled={inFlight || resolved}
|
||||
className="px-2 py-1 text-xs rounded border border-gray-200 bg-white text-gray-700 hover:bg-gray-100 disabled:opacity-50"
|
||||
>
|
||||
{status === "rejected" ? "Rejected" : "Reject"}
|
||||
</button>
|
||||
</PillButton>
|
||||
{onViewClick && (
|
||||
<button
|
||||
<PillButton
|
||||
tone="blue"
|
||||
size="sm"
|
||||
onClick={() => onViewClick(annotation)}
|
||||
disabled={resolved}
|
||||
title={
|
||||
|
|
@ -317,10 +327,10 @@ export function EditCard({
|
|||
? "This change has been resolved and is no longer in the document."
|
||||
: undefined
|
||||
}
|
||||
className="ml-auto px-2 py-1 text-xs rounded border border-gray-200 bg-white text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white"
|
||||
className="ml-auto"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
</PillButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ import { useEffect, useState } from "react";
|
|||
import { useRouter } from "next/navigation";
|
||||
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
|
||||
import { useDirectoryData } from "../shared/useDirectoryData";
|
||||
import { ProjectPicker } from "../shared/ProjectPicker";
|
||||
import { Modal } from "../shared/Modal";
|
||||
import { ProjectPickerModal } from "../modals/ProjectPickerModal";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
|
@ -40,22 +39,19 @@ export function SelectAssistantProjectModal({ open, onClose }: Props) {
|
|||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<ProjectPickerModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
breadcrumbs={["Assistant", "Start Chat in a Project"]}
|
||||
projects={projects}
|
||||
loading={loading}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
primaryAction={{
|
||||
label: creating ? "Creating…" : "Continue",
|
||||
onClick: handleContinue,
|
||||
disabled: !selectedId || creating,
|
||||
}}
|
||||
>
|
||||
<ProjectPicker
|
||||
projects={projects}
|
||||
loading={loading}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
</Modal>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { File, FileText, Library } from "lucide-react";
|
||||
import { Library } from "lucide-react";
|
||||
import { FileTypeIcon } from "../shared/FileTypeIcon";
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
|
|
@ -23,22 +24,15 @@ export function UserMessage({ content, files, workflow }: Props) {
|
|||
<span className="max-w-[140px] truncate">{workflow.title}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasFiles && files.map((f, i) => {
|
||||
const ext = f.filename.split(".").pop()?.toLowerCase();
|
||||
const isPdf = ext === "pdf";
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1 rounded-[10px] border border-white/70 bg-white py-0.5 pl-2 pr-2.5 text-xs text-gray-800 shadow-[0_2px_6px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9)] backdrop-blur-xl"
|
||||
>
|
||||
{isPdf
|
||||
? <FileText className="h-2.5 w-2.5 shrink-0 text-red-500" />
|
||||
: <File className="h-2.5 w-2.5 shrink-0 text-blue-500" />
|
||||
}
|
||||
<span className="max-w-[140px] truncate">{f.filename}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hasFiles && files.map((f, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1 rounded-[10px] border border-white/70 bg-white py-0.5 pl-2 pr-2.5 text-xs text-gray-800 shadow-[0_2px_6px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9)] backdrop-blur-xl"
|
||||
>
|
||||
<FileTypeIcon fileType={f.filename} className="h-2.5 w-2.5" />
|
||||
<span className="max-w-[140px] truncate">{f.filename}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
import { X } from "lucide-react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface CreditsExhaustedModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
resetDate: string;
|
||||
}
|
||||
|
||||
export function CreditsExhaustedModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
resetDate,
|
||||
}: CreditsExhaustedModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Format the reset date
|
||||
const formatResetDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-[200]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-[201] w-full max-w-md px-4">
|
||||
<div className="relative bg-white rounded-2xl shadow-2xl p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h2 className="text-3xl font-light font-eb-garamond text-gray-900">
|
||||
Message Limit Reached
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600">
|
||||
You've reached your monthly message limit of 100
|
||||
messages.
|
||||
</p>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-900 font-medium mb-1">
|
||||
Your credits will reset on:
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-blue-700">
|
||||
{formatResetDate(resetDate)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500">
|
||||
Your message credits automatically reset on the
|
||||
first day of each month.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Check } from "lucide-react";
|
||||
|
||||
interface DeleteChatsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
chatCount: number;
|
||||
isDeleting: boolean;
|
||||
isSuccess?: boolean;
|
||||
}
|
||||
|
||||
export function DeleteChatsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
chatCount,
|
||||
isDeleting,
|
||||
isSuccess = false,
|
||||
}: DeleteChatsModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-199"
|
||||
onClick={isDeleting || isSuccess ? undefined : onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-200 w-full max-w-md">
|
||||
<div className="bg-white rounded-2xl shadow-2xl p-8">
|
||||
{isSuccess ? (
|
||||
<>
|
||||
{/* Success State */}
|
||||
<div className="text-center">
|
||||
<div className="mx-auto w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
|
||||
<Check className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-light font-eb-garamond text-gray-900 mb-2">
|
||||
All Chats Deleted
|
||||
</h2>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Your chat history has been successfully
|
||||
deleted.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-4xl font-light font-eb-garamond text-red-700">
|
||||
Delete All Chats
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
Are you sure you want to delete all{" "}
|
||||
{chatCount} chat
|
||||
{chatCount !== 1 ? "s" : ""}? This action is
|
||||
permanent and cannot be undone.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3 pt-4">
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
disabled={isDeleting}
|
||||
variant="destructive"
|
||||
className="w-full bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
{isDeleting
|
||||
? "Deleting..."
|
||||
: "Delete All Chats"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
disabled={isDeleting}
|
||||
className="w-full border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import { X, Link2, Check } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface SimpleLinkDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
shareUrl: string | null;
|
||||
}
|
||||
|
||||
export function SimpleLinkDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
shareUrl,
|
||||
}: SimpleLinkDialogProps) {
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
if (!shareUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
setLinkCopied(true);
|
||||
setTimeout(() => setLinkCopied(false), 2000);
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-[199]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-[200] w-full max-w-md px-4">
|
||||
<div className="relative bg-white rounded-2xl shadow-2xl p-6">
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-3xl font-light font-eb-garamond text-gray-900">
|
||||
Share Chat
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-4">
|
||||
{/* Link display */}
|
||||
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200">
|
||||
<p className="text-sm text-gray-600 mb-2 font-medium">
|
||||
Share Link
|
||||
</p>
|
||||
<p className="text-sm text-gray-800 break-all font-mono">
|
||||
{shareUrl}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Copy button */}
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
className="w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white py-2.5 px-4 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
{linkCopied ? (
|
||||
<>
|
||||
<Check className="h-5 w-5" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link2 className="h-5 w-5" />
|
||||
Copy Link
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
|
@ -12,10 +12,12 @@ import {
|
|||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
|
||||
import { DocView } from "@/app/components/shared/DocView";
|
||||
import { WarningPopup } from "@/app/components/shared/WarningPopup";
|
||||
import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
|
||||
import { PdfView } from "@/app/components/shared/views/PdfView";
|
||||
import { SpreadsheetView } from "@/app/components/shared/views/SpreadsheetView";
|
||||
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 { formatBytes } from "./ProjectPageParts";
|
||||
|
|
@ -163,7 +165,7 @@ export function DocumentSidePanel({
|
|||
|
||||
const activeDoc = doc;
|
||||
const documentId = activeDoc.id;
|
||||
const newVersionAccept = ".pdf,.docx,.doc";
|
||||
const newVersionAccept = ".pdf,.docx,.doc,.xlsx,.xlsm,.xls,.pptx,.ppt";
|
||||
const orderedVersions = [...versions].reverse();
|
||||
const activeVersionCount = versions.filter(
|
||||
(version) => version.deleted_at == null,
|
||||
|
|
@ -481,15 +483,25 @@ export function DocumentSidePanel({
|
|||
"rounded-xl border border-gray-200 bg-white/55 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] backdrop-blur-xl",
|
||||
)}
|
||||
>
|
||||
<DocView
|
||||
key={`${selectedVersionId ?? "current"}:${selectedUploadedAt ?? ""}:${selectedSizeBytes ?? ""}`}
|
||||
doc={{
|
||||
document_id: doc.id,
|
||||
version_id: selectedVersionId,
|
||||
}}
|
||||
rounded={false}
|
||||
bordered={false}
|
||||
/>
|
||||
{isSpreadsheetFilename(selectedFilename) ? (
|
||||
<SpreadsheetView
|
||||
key={`${selectedVersionId ?? "current"}:${selectedUploadedAt ?? ""}:${selectedSizeBytes ?? ""}`}
|
||||
documentId={doc.id}
|
||||
versionId={selectedVersionId}
|
||||
rounded={false}
|
||||
bordered={false}
|
||||
/>
|
||||
) : (
|
||||
<PdfView
|
||||
key={`${selectedVersionId ?? "current"}:${selectedUploadedAt ?? ""}:${selectedSizeBytes ?? ""}`}
|
||||
doc={{
|
||||
document_id: doc.id,
|
||||
version_id: selectedVersionId,
|
||||
}}
|
||||
rounded={false}
|
||||
bordered={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Users, Upload } from "lucide-react";
|
||||
import { Upload, User, X } from "lucide-react";
|
||||
import {
|
||||
addDocumentToProject,
|
||||
createProject,
|
||||
|
|
@ -9,10 +9,14 @@ import {
|
|||
} from "@/app/lib/mikeApi";
|
||||
import { useDirectoryData } from "../shared/useDirectoryData";
|
||||
import { FileDirectory } from "../shared/FileDirectory";
|
||||
import { EmailPillInput } from "../shared/EmailPillInput";
|
||||
import { AddUserInput } from "../shared/AddUserInput";
|
||||
import type { Project } from "../shared/types";
|
||||
import type { UserLookupResult } from "@/app/lib/mikeApi";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { Modal } from "../shared/Modal";
|
||||
import { Modal } from "../modals/Modal";
|
||||
import { ModalFieldLabel } from "../modals/ModalFieldLabel";
|
||||
import { ModalTextInput } from "../modals/ModalTextInput";
|
||||
import { ProjectPracticeField } from "./ProjectPracticeField";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
|
@ -21,10 +25,11 @@ interface Props {
|
|||
}
|
||||
|
||||
export function NewProjectModal({ open, onClose, onCreated }: Props) {
|
||||
const [step, setStep] = useState<"details" | "documents">("details");
|
||||
const [name, setName] = useState("");
|
||||
const [cmNumber, setCmNumber] = useState("");
|
||||
const [sharedEmails, setSharedEmails] = useState<string[]>([]);
|
||||
const [showMembers, setShowMembers] = useState(false);
|
||||
const [practice, setPractice] = useState("");
|
||||
const [sharedUsers, setSharedUsers] = useState<UserLookupResult[]>([]);
|
||||
const [selectedDocIds, setSelectedDocIds] = useState<Set<string>>(new Set());
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -38,6 +43,14 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
|
|||
|
||||
if (!open) return null;
|
||||
|
||||
function submitterValue(e: React.FormEvent<HTMLFormElement>) {
|
||||
return (
|
||||
(e.nativeEvent as SubmitEvent).submitter as
|
||||
| HTMLButtonElement
|
||||
| null
|
||||
)?.value;
|
||||
}
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
e.target.value = "";
|
||||
|
|
@ -45,18 +58,27 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
|
|||
setPendingFiles((prev) => [...prev, ...files.filter((f) => !prev.some((p) => p.name === f.name))]);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
if (step === "details" || submitterValue(e) !== "create-project") {
|
||||
setStep("documents");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const project = await createProject(
|
||||
name.trim(),
|
||||
cmNumber.trim() || undefined,
|
||||
practice.trim() && practice.trim() !== "Other"
|
||||
? practice.trim()
|
||||
: undefined,
|
||||
ownEmail
|
||||
? sharedEmails.filter((email) => email !== ownEmail)
|
||||
: sharedEmails,
|
||||
? sharedUsers
|
||||
.map((user) => user.email)
|
||||
.filter((email) => email !== ownEmail)
|
||||
: sharedUsers.map((user) => user.email),
|
||||
);
|
||||
await Promise.all([
|
||||
...[...selectedDocIds].map((id) => addDocumentToProject(project.id, id).catch(() => {})),
|
||||
|
|
@ -73,10 +95,11 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
|
|||
}
|
||||
|
||||
function resetForm() {
|
||||
setStep("details");
|
||||
setName("");
|
||||
setCmNumber("");
|
||||
setSharedEmails([]);
|
||||
setShowMembers(false);
|
||||
setPractice("");
|
||||
setSharedUsers([]);
|
||||
setSelectedDocIds(new Set());
|
||||
setPendingFiles([]);
|
||||
setError("");
|
||||
|
|
@ -87,22 +110,88 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
|
|||
onClose();
|
||||
}
|
||||
|
||||
function validateShareUser(email: string) {
|
||||
if (ownEmail && email === ownEmail) {
|
||||
return "You cannot share a project with yourself.";
|
||||
}
|
||||
if (
|
||||
sharedUsers.some(
|
||||
(user) => user.email.trim().toLowerCase() === email,
|
||||
)
|
||||
) {
|
||||
return `${email} already has access.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleAddShareUser(user: UserLookupResult) {
|
||||
setSharedUsers((prev) => [
|
||||
...prev,
|
||||
{
|
||||
...user,
|
||||
email: user.email.trim().toLowerCase(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function handleRemoveShareUser(email: string) {
|
||||
setSharedUsers((prev) =>
|
||||
prev.filter(
|
||||
(user) =>
|
||||
user.email.trim().toLowerCase() !==
|
||||
email.trim().toLowerCase(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
breadcrumbs={["Projects", "New project"]}
|
||||
secondaryAction={{
|
||||
label: `Upload files${pendingFiles.length > 0 ? ` (${pendingFiles.length})` : ""}`,
|
||||
icon: <Upload className="h-3.5 w-3.5" />,
|
||||
onClick: () => fileInputRef.current?.click(),
|
||||
}}
|
||||
primaryAction={{
|
||||
label: loading ? "Creating…" : "Create project",
|
||||
type: "submit",
|
||||
form: formId,
|
||||
disabled: !name.trim() || loading,
|
||||
}}
|
||||
breadcrumbs={[
|
||||
"Projects",
|
||||
"New project",
|
||||
step === "details" ? "Details" : "Documents",
|
||||
]}
|
||||
secondaryAction={
|
||||
step === "documents"
|
||||
? {
|
||||
label: `Upload documents${pendingFiles.length > 0 ? ` (${pendingFiles.length})` : ""}`,
|
||||
icon: <Upload className="h-3.5 w-3.5" />,
|
||||
onClick: () => fileInputRef.current?.click(),
|
||||
disabled: loading,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
cancelAction={
|
||||
step === "documents"
|
||||
? {
|
||||
label: "Back",
|
||||
onClick: () => setStep("details"),
|
||||
disabled: loading,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
primaryAction={
|
||||
step === "details"
|
||||
? {
|
||||
label: "Next",
|
||||
type: "button",
|
||||
onClick: (event) => {
|
||||
event.preventDefault();
|
||||
setStep("documents");
|
||||
},
|
||||
disabled: !name.trim() || loading,
|
||||
}
|
||||
: {
|
||||
label: loading ? "Creating…" : "Create project",
|
||||
type: "submit",
|
||||
form: formId,
|
||||
name: "modalAction",
|
||||
value: "create-project",
|
||||
disabled: !name.trim() || loading,
|
||||
}
|
||||
}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
|
@ -116,61 +205,124 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
|
|||
onSubmit={handleSubmit}
|
||||
className="flex flex-col flex-1 min-h-0"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Project name"
|
||||
className="w-full text-2xl font-serif text-gray-800 placeholder-gray-300 focus:outline-none bg-transparent"
|
||||
autoFocus
|
||||
/>
|
||||
{step === "details" ? (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<ModalFieldLabel htmlFor="new-project-name">
|
||||
Project name
|
||||
</ModalFieldLabel>
|
||||
<ModalTextInput
|
||||
id="new-project-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Add project name"
|
||||
variant="minimal"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={cmNumber}
|
||||
onChange={(e) => setCmNumber(e.target.value)}
|
||||
placeholder="Add a CM number..."
|
||||
className="mt-1.5 w-full text-sm text-gray-500 placeholder-gray-300 focus:outline-none bg-transparent"
|
||||
/>
|
||||
<div>
|
||||
<ModalFieldLabel htmlFor="new-project-cm-number">
|
||||
CM number
|
||||
</ModalFieldLabel>
|
||||
<ModalTextInput
|
||||
id="new-project-cm-number"
|
||||
type="text"
|
||||
value={cmNumber}
|
||||
onChange={(e) => setCmNumber(e.target.value)}
|
||||
placeholder="Add a CM number..."
|
||||
variant="minimal"
|
||||
className="text-xl text-gray-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMembers((v) => !v)}
|
||||
className="flex items-center gap-1.5 rounded-full border border-gray-200 px-3 py-1 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Users className="h-3 w-3 text-gray-400" />
|
||||
Members{sharedEmails.length > 0 ? ` (${sharedEmails.length})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<ModalFieldLabel htmlFor="new-project-practice">
|
||||
Practice
|
||||
</ModalFieldLabel>
|
||||
<ProjectPracticeField
|
||||
id="new-project-practice"
|
||||
value={practice}
|
||||
onChange={setPractice}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showMembers && (
|
||||
<div className="mt-3">
|
||||
<EmailPillInput
|
||||
emails={sharedEmails}
|
||||
onChange={setSharedEmails}
|
||||
validate={async (email) =>
|
||||
ownEmail && email === ownEmail
|
||||
? "You cannot share a project with yourself."
|
||||
: null
|
||||
}
|
||||
placeholder="Add colleagues by email…"
|
||||
<div className="space-y-2">
|
||||
<ModalFieldLabel as="p">
|
||||
Share with
|
||||
</ModalFieldLabel>
|
||||
<AddUserInput
|
||||
onAdd={handleAddShareUser}
|
||||
validateEmail={validateShareUser}
|
||||
placeholder="Add colleagues by email..."
|
||||
/>
|
||||
{sharedUsers.length > 0 && (
|
||||
<ul className="space-y-1 pt-1">
|
||||
{sharedUsers.map((entry) => {
|
||||
const displayName =
|
||||
entry.display_name?.trim();
|
||||
const primary = displayName || "User";
|
||||
const initial = displayName
|
||||
?.charAt(0)
|
||||
.toUpperCase();
|
||||
return (
|
||||
<li
|
||||
key={entry.email}
|
||||
className="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}
|
||||
<span className="text-gray-400">
|
||||
{" "}
|
||||
· {entry.email}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleRemoveShareUser(
|
||||
entry.email,
|
||||
)
|
||||
}
|
||||
className="self-center inline-flex items-center rounded-full px-2 py-1 text-xs text-gray-500 transition-colors hover:text-red-600"
|
||||
aria-label={`Remove ${entry.email}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1">
|
||||
<FileDirectory
|
||||
standaloneDocs={standaloneDocuments}
|
||||
directoryProjects={dirProjects}
|
||||
loading={dirLoading}
|
||||
selectedIds={selectedDocIds}
|
||||
onChange={setSelectedDocIds}
|
||||
emptyMessage="No existing documents"
|
||||
searchable
|
||||
searchAutoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-xs font-medium text-gray-700">Select documents</p>
|
||||
<FileDirectory
|
||||
standaloneDocs={standaloneDocuments}
|
||||
directoryProjects={dirProjects}
|
||||
loading={dirLoading}
|
||||
selectedIds={selectedDocIds}
|
||||
onChange={setSelectedDocIds}
|
||||
emptyMessage="No existing documents"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-3 text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -67,34 +67,37 @@ export function ProjectAssistantTable({
|
|||
loading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TableScrollArea>
|
||||
<TableHeaderRow className="pr-8 md:pr-8">
|
||||
<TableStickyCell header>
|
||||
{loading ? (
|
||||
<SkeletonDot />
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChatsSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someChatsSelected;
|
||||
}}
|
||||
onChange={() => {
|
||||
if (allChatsSelected) setSelectedChatIds([]);
|
||||
else
|
||||
setSelectedChatIds(
|
||||
filteredChats.map((c) => c.id),
|
||||
);
|
||||
}}
|
||||
className={TABLE_CHECKBOX_CLASS}
|
||||
/>
|
||||
)}
|
||||
<span>Chats</span>
|
||||
</TableStickyCell>
|
||||
<TableHeaderCell className="ml-auto w-32">Creator</TableHeaderCell>
|
||||
<TableHeaderCell className="w-32">Created</TableHeaderCell>
|
||||
<TableHeaderCell className="w-8" />
|
||||
</TableHeaderRow>
|
||||
<TableScrollArea
|
||||
header={
|
||||
<TableHeaderRow className="pr-8 md:pr-8">
|
||||
<TableStickyCell header>
|
||||
{loading ? (
|
||||
<SkeletonDot />
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChatsSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someChatsSelected;
|
||||
}}
|
||||
onChange={() => {
|
||||
if (allChatsSelected) setSelectedChatIds([]);
|
||||
else
|
||||
setSelectedChatIds(
|
||||
filteredChats.map((c) => c.id),
|
||||
);
|
||||
}}
|
||||
className={TABLE_CHECKBOX_CLASS}
|
||||
/>
|
||||
)}
|
||||
<span>Chats</span>
|
||||
</TableStickyCell>
|
||||
<TableHeaderCell className="ml-auto w-32">Creator</TableHeaderCell>
|
||||
<TableHeaderCell className="w-32">Created</TableHeaderCell>
|
||||
<TableHeaderCell className="w-8" />
|
||||
</TableHeaderRow>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<ProjectAssistantLoadingRows />
|
||||
) : chats.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -1,38 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { Loader2, Users } from "lucide-react";
|
||||
import { Modal } from "@/app/components/shared/Modal";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Users } from "lucide-react";
|
||||
import { Modal } from "@/app/components/modals/Modal";
|
||||
import { ModalFieldLabel } from "@/app/components/modals/ModalFieldLabel";
|
||||
import { ModalTextInput } from "@/app/components/modals/ModalTextInput";
|
||||
import type { Project } from "@/app/components/shared/types";
|
||||
import type { ProjectPeople } from "@/app/lib/mikeApi";
|
||||
import { ProjectPracticeField } from "./ProjectPracticeField";
|
||||
|
||||
interface ProjectDetailsModalProps {
|
||||
open: boolean;
|
||||
project: Project | null;
|
||||
canEdit: boolean;
|
||||
currentUserDisplayName?: string | null;
|
||||
currentUserEmail?: string | null;
|
||||
fetchPeople: (projectId: string) => Promise<ProjectPeople>;
|
||||
onClose: () => void;
|
||||
onSave: (values: { name: string; cmNumber: string }) => Promise<void>;
|
||||
onShareProject: () => void;
|
||||
onSave: (values: { name: string; cmNumber: string; practice: string }) => Promise<void>;
|
||||
onShareProject?: () => void;
|
||||
}
|
||||
|
||||
export function ProjectDetailsModal({
|
||||
open,
|
||||
project,
|
||||
canEdit,
|
||||
currentUserDisplayName,
|
||||
currentUserEmail,
|
||||
fetchPeople,
|
||||
onClose,
|
||||
onSave,
|
||||
onShareProject,
|
||||
}: ProjectDetailsModalProps) {
|
||||
const [nameDraft, setNameDraft] = useState("");
|
||||
const [cmDraft, setCmDraft] = useState("");
|
||||
const [people, setPeople] = useState<ProjectPeople | null>(null);
|
||||
const [peopleLoading, setPeopleLoading] = useState(false);
|
||||
const [practiceDraft, setPracticeDraft] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -41,70 +36,39 @@ export function ProjectDetailsModal({
|
|||
if (!open || !project) return;
|
||||
setNameDraft(project.name);
|
||||
setCmDraft(project.cm_number ?? "");
|
||||
setPracticeDraft(project.practice ?? "");
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
}, [open, project]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !project) return;
|
||||
const isPrivateOwnedProject =
|
||||
project.is_owner !== false &&
|
||||
(!Array.isArray(project.shared_with) ||
|
||||
project.shared_with.length === 0);
|
||||
if (isPrivateOwnedProject) {
|
||||
setPeople(null);
|
||||
setPeopleLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setPeopleLoading(true);
|
||||
fetchPeople(project.id)
|
||||
.then((data) => {
|
||||
if (!cancelled) setPeople(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPeople(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPeopleLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, project, fetchPeople]);
|
||||
|
||||
const trimmedName = nameDraft.trim();
|
||||
const trimmedCm = cmDraft.trim();
|
||||
const trimmedPractice = practiceDraft.trim();
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!project) return false;
|
||||
return (
|
||||
trimmedName !== project.name ||
|
||||
trimmedCm !== (project.cm_number ?? "")
|
||||
trimmedCm !== (project.cm_number ?? "") ||
|
||||
trimmedPractice !== (project.practice ?? "")
|
||||
);
|
||||
}, [project, trimmedCm, trimmedName]);
|
||||
}, [project, trimmedCm, trimmedName, trimmedPractice]);
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
const accessLabel =
|
||||
Array.isArray(project.shared_with) && project.shared_with.length > 0
|
||||
? "Shared"
|
||||
: "Private";
|
||||
const isPrivateOwnedProject =
|
||||
project.is_owner !== false && accessLabel === "Private";
|
||||
const ownerLabel =
|
||||
people?.owner.display_name?.trim() ||
|
||||
people?.owner.email?.trim() ||
|
||||
(isPrivateOwnedProject ? currentUserDisplayName?.trim() : "") ||
|
||||
(isPrivateOwnedProject ? currentUserEmail?.trim() : "") ||
|
||||
"Unknown";
|
||||
|
||||
async function handleSave() {
|
||||
if (!canEdit || saving || !hasChanges || !trimmedName) return;
|
||||
setSaving(true);
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave({ name: trimmedName, cmNumber: trimmedCm });
|
||||
await onSave({
|
||||
name: trimmedName,
|
||||
cmNumber: trimmedCm,
|
||||
practice:
|
||||
trimmedPractice && trimmedPractice !== "Other"
|
||||
? trimmedPractice
|
||||
: "",
|
||||
});
|
||||
setSaved(true);
|
||||
} catch {
|
||||
setError("Could not update project details.");
|
||||
|
|
@ -118,11 +82,15 @@ export function ProjectDetailsModal({
|
|||
open={open}
|
||||
onClose={onClose}
|
||||
breadcrumbs={["Projects", project.name, "Details"]}
|
||||
secondaryAction={{
|
||||
label: "Share Project",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
onClick: onShareProject,
|
||||
}}
|
||||
secondaryAction={
|
||||
onShareProject
|
||||
? {
|
||||
label: "Share Project",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
onClick: onShareProject,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
footerStatus={
|
||||
error ? (
|
||||
<span className="text-sm text-red-600">{error}</span>
|
||||
|
|
@ -141,15 +109,12 @@ export function ProjectDetailsModal({
|
|||
}
|
||||
cancelAction={canEdit ? undefined : false}
|
||||
>
|
||||
<div className="flex flex-col gap-5 py-1">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label
|
||||
htmlFor="project-details-name"
|
||||
className="text-xs font-medium text-gray-700"
|
||||
>
|
||||
Project Name
|
||||
</label>
|
||||
<input
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-6 py-1">
|
||||
<div>
|
||||
<ModalFieldLabel htmlFor="project-details-name">
|
||||
Project name
|
||||
</ModalFieldLabel>
|
||||
<ModalTextInput
|
||||
id="project-details-name"
|
||||
value={nameDraft}
|
||||
onChange={(e) => {
|
||||
|
|
@ -158,18 +123,16 @@ export function ProjectDetailsModal({
|
|||
setError(null);
|
||||
}}
|
||||
disabled={!canEdit || saving}
|
||||
className="h-9 w-full rounded-md border border-gray-200 bg-gray-50 px-3 text-sm text-gray-900 outline-none transition-colors focus:border-gray-300 disabled:cursor-not-allowed disabled:text-gray-400"
|
||||
placeholder="Add project name"
|
||||
variant="minimal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<label
|
||||
htmlFor="project-details-cm"
|
||||
className="text-xs font-medium text-gray-700"
|
||||
>
|
||||
CM
|
||||
</label>
|
||||
<input
|
||||
<div>
|
||||
<ModalFieldLabel htmlFor="project-details-cm">
|
||||
CM number
|
||||
</ModalFieldLabel>
|
||||
<ModalTextInput
|
||||
id="project-details-cm"
|
||||
value={cmDraft}
|
||||
onChange={(e) => {
|
||||
|
|
@ -178,39 +141,29 @@ export function ProjectDetailsModal({
|
|||
setError(null);
|
||||
}}
|
||||
disabled={!canEdit || saving}
|
||||
placeholder="No CM"
|
||||
className="h-9 w-full rounded-md border border-gray-200 bg-gray-50 px-3 text-sm text-gray-900 outline-none transition-colors focus:border-gray-300 disabled:cursor-not-allowed disabled:text-gray-400"
|
||||
placeholder="Add a CM number..."
|
||||
variant="minimal"
|
||||
className="text-xl text-gray-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-100 text-sm">
|
||||
<DetailRow label="Ownership" value={accessLabel} />
|
||||
<DetailRow
|
||||
label="Owner"
|
||||
value={
|
||||
peopleLoading && !isPrivateOwnedProject ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-gray-400">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Loading
|
||||
</span>
|
||||
) : (
|
||||
ownerLabel
|
||||
)
|
||||
}
|
||||
<div>
|
||||
<ModalFieldLabel htmlFor="project-details-practice">
|
||||
Practice
|
||||
</ModalFieldLabel>
|
||||
<ProjectPracticeField
|
||||
id="project-details-practice"
|
||||
value={practiceDraft}
|
||||
onChange={(value) => {
|
||||
setPracticeDraft(value);
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={!canEdit || saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-3">
|
||||
<span className="text-gray-500">{label}</span>
|
||||
<span className="min-w-0 truncate text-right text-gray-900">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,10 +43,10 @@ import {
|
|||
import {
|
||||
AddDocumentsModal,
|
||||
invalidateDirectoryCache,
|
||||
} from "@/app/components/shared/AddDocumentsModal";
|
||||
} from "@/app/components/modals/AddDocumentsModal";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { WarningPopup } from "@/app/components/shared/WarningPopup";
|
||||
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
|
||||
import { WarningPopup } from "@/app/components/popups/WarningPopup";
|
||||
import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
|
||||
import {
|
||||
formatUnsupportedDocumentWarning,
|
||||
partitionSupportedDocumentFiles,
|
||||
|
|
@ -92,9 +92,9 @@ function ProjectTableLoading({
|
|||
}) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none shrink-0">
|
||||
<div className={`sticky top-0 z-[70] ${stickyCellBg} flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none shrink-0`}>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}
|
||||
className={`sticky left-0 z-[80] ${DOC_NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}
|
||||
>
|
||||
<div className="h-2.5 w-2.5 rounded bg-gray-100 animate-pulse" />
|
||||
<span>Name</span>
|
||||
|
|
@ -1893,7 +1893,7 @@ export function ProjectDocumentsView({ projectId }: Props) {
|
|||
const sidePanelDoc = viewingDoc
|
||||
? (docs.find((doc) => doc.id === viewingDoc.id) ?? viewingDoc)
|
||||
: null;
|
||||
const versionUploadAccept = ".pdf,.docx,.doc";
|
||||
const versionUploadAccept = ".pdf,.docx,.doc,.xlsx,.xlsm,.xls,.pptx,.ppt";
|
||||
const q = search.toLowerCase();
|
||||
const filteredDocs = q
|
||||
? docs.filter((d) => d.filename.toLowerCase().includes(q))
|
||||
|
|
@ -2140,9 +2140,9 @@ export function ProjectDocumentsView({ projectId }: Props) {
|
|||
) : (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Table header */}
|
||||
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none shrink-0">
|
||||
<div className={`sticky top-0 z-[70] ${stickyCellBg} flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none shrink-0`}>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}
|
||||
className={`sticky left-0 z-[80] ${DOC_NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -2233,7 +2233,7 @@ export function ProjectDocumentsView({ projectId }: Props) {
|
|||
>
|
||||
<Upload className="h-8 w-8 text-gray-200 mb-3" />
|
||||
<p className="text-sm text-gray-400">
|
||||
Drop PDF, DOCX, or DOC files here
|
||||
Drop PDF, Word, Excel, or PowerPoint files here
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@
|
|||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
FileText,
|
||||
File,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
ChevronRight,
|
||||
|
|
@ -16,6 +14,7 @@ import type {
|
|||
Folder as ProjectFolder,
|
||||
} from "@/app/components/shared/types";
|
||||
import { VersionChip } from "@/app/components/shared/VersionChip";
|
||||
import { FileTypeIcon } from "@/app/components/shared/FileTypeIcon";
|
||||
|
||||
interface Props {
|
||||
projectName?: string | null;
|
||||
|
|
@ -32,11 +31,7 @@ interface Props {
|
|||
}
|
||||
|
||||
function DocIcon({ fileType }: { fileType: string | null }) {
|
||||
if (fileType === "pdf")
|
||||
return <FileText className="h-3.5 w-3.5 text-red-500 shrink-0" />;
|
||||
if (fileType === "docx" || fileType === "doc")
|
||||
return <File className="h-3.5 w-3.5 text-blue-500 shrink-0" />;
|
||||
return <File className="h-3.5 w-3.5 text-gray-400 shrink-0" />;
|
||||
return <FileTypeIcon fileType={fileType} className="h-3.5 w-3.5" />;
|
||||
}
|
||||
|
||||
type ContextMenuState = {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,6 @@
|
|||
import { type CSSProperties, useRef, useState } from "react";
|
||||
import {
|
||||
CornerDownRight,
|
||||
File,
|
||||
FileText,
|
||||
Info,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
|
|
@ -14,6 +11,7 @@ import {
|
|||
Users,
|
||||
} from "lucide-react";
|
||||
import { PageHeader } from "@/app/components/shared/PageHeader";
|
||||
import { FileTypeIcon } from "@/app/components/shared/FileTypeIcon";
|
||||
import type { Project } from "@/app/components/shared/types";
|
||||
import type { DocumentVersion } from "@/app/lib/mikeApi";
|
||||
import { RowActions } from "@/app/components/shared/RowActions";
|
||||
|
|
@ -65,12 +63,7 @@ export function DocIcon({
|
|||
fileType: string | null;
|
||||
muted?: boolean;
|
||||
}) {
|
||||
if (muted) return <File className="h-4 w-4 text-gray-300 shrink-0" />;
|
||||
if (fileType === "pdf")
|
||||
return <FileText className="h-4 w-4 text-red-600 shrink-0" />;
|
||||
if (fileType === "docx" || fileType === "doc")
|
||||
return <File className="h-4 w-4 text-blue-600 shrink-0" />;
|
||||
return <File className="h-4 w-4 text-gray-500 shrink-0" />;
|
||||
return <FileTypeIcon fileType={fileType} className="h-4 w-4" muted={muted} />;
|
||||
}
|
||||
|
||||
export function DocVersionHistory({
|
||||
|
|
@ -361,7 +354,6 @@ export function ProjectPageHeader({
|
|||
docsCount,
|
||||
isOwner,
|
||||
onBackToProjects,
|
||||
onOwnerOnly,
|
||||
onOpenDetails,
|
||||
onDeleteProject,
|
||||
onSearchChange,
|
||||
|
|
@ -376,7 +368,6 @@ export function ProjectPageHeader({
|
|||
docsCount: number;
|
||||
isOwner: boolean;
|
||||
onBackToProjects: () => void;
|
||||
onOwnerOnly: (action: string) => void;
|
||||
onOpenDetails: () => void;
|
||||
onDeleteProject: () => void;
|
||||
onSearchChange: (search: string) => void;
|
||||
|
|
@ -384,25 +375,6 @@ export function ProjectPageHeader({
|
|||
onNewChat: () => void;
|
||||
onNewReview: () => void;
|
||||
}) {
|
||||
const requestRename = () => {
|
||||
if (!project) return;
|
||||
if (!isOwner) {
|
||||
onOwnerOnly("rename this project");
|
||||
return;
|
||||
}
|
||||
onOpenDetails();
|
||||
};
|
||||
|
||||
const titleLabel = !project ? undefined : (
|
||||
<span
|
||||
onClick={requestRename}
|
||||
className="inline-block cursor-text"
|
||||
title="Rename"
|
||||
>
|
||||
{project.name}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageHeader
|
||||
breadcrumbs={[
|
||||
|
|
@ -414,8 +386,7 @@ export function ProjectPageHeader({
|
|||
{
|
||||
...(project
|
||||
? {
|
||||
label: titleLabel,
|
||||
cursor: "text",
|
||||
label: project.name,
|
||||
}
|
||||
: {
|
||||
loading: true,
|
||||
|
|
@ -443,13 +414,10 @@ export function ProjectPageHeader({
|
|||
<HeaderActionsMenu
|
||||
items={[
|
||||
{
|
||||
label: "Rename",
|
||||
label: isOwner
|
||||
? "Edit details"
|
||||
: "View details",
|
||||
icon: Pencil,
|
||||
onSelect: requestRename,
|
||||
},
|
||||
{
|
||||
label: "Project Details",
|
||||
icon: Info,
|
||||
onSelect: onOpenDetails,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -31,18 +31,14 @@ export function ProjectReviewsTable({
|
|||
selectedReviewIds,
|
||||
allReviewsSelected,
|
||||
someReviewsSelected,
|
||||
renamingReviewId,
|
||||
renameReviewValue,
|
||||
creatingReview,
|
||||
currentUserId,
|
||||
onCreateReview,
|
||||
onOpenReview,
|
||||
onOpenDetails,
|
||||
onDeleteReview,
|
||||
onOwnerOnlyAction,
|
||||
submitReviewRename,
|
||||
setSelectedReviewIds,
|
||||
setRenamingReviewId,
|
||||
setRenameReviewValue,
|
||||
loading = false,
|
||||
}: {
|
||||
docs: Document[];
|
||||
|
|
@ -51,50 +47,49 @@ export function ProjectReviewsTable({
|
|||
selectedReviewIds: string[];
|
||||
allReviewsSelected: boolean;
|
||||
someReviewsSelected: boolean;
|
||||
renamingReviewId: string | null;
|
||||
renameReviewValue: string;
|
||||
creatingReview: boolean;
|
||||
currentUserId?: string | null;
|
||||
onCreateReview: () => void;
|
||||
onOpenReview: (reviewId: string) => void;
|
||||
onOpenDetails: (review: TabularReview) => void;
|
||||
onDeleteReview: (review: TabularReview) => Promise<void> | void;
|
||||
onOwnerOnlyAction: (action: string) => void;
|
||||
submitReviewRename: (reviewId: string) => Promise<void> | void;
|
||||
setSelectedReviewIds: Dispatch<SetStateAction<string[]>>;
|
||||
setRenamingReviewId: Dispatch<SetStateAction<string | null>>;
|
||||
setRenameReviewValue: Dispatch<SetStateAction<string>>;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TableScrollArea>
|
||||
<TableHeaderRow className="pr-8 md:pr-8">
|
||||
<TableStickyCell header>
|
||||
{loading ? (
|
||||
<SkeletonDot />
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allReviewsSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someReviewsSelected;
|
||||
}}
|
||||
onChange={() => {
|
||||
if (allReviewsSelected) setSelectedReviewIds([]);
|
||||
else
|
||||
setSelectedReviewIds(
|
||||
filteredReviews.map((r) => r.id),
|
||||
);
|
||||
}}
|
||||
className={TABLE_CHECKBOX_CLASS}
|
||||
/>
|
||||
)}
|
||||
<span>Name</span>
|
||||
</TableStickyCell>
|
||||
<TableHeaderCell className="ml-auto w-24">Columns</TableHeaderCell>
|
||||
<TableHeaderCell className="w-24">Documents</TableHeaderCell>
|
||||
<TableHeaderCell className="w-32">Created</TableHeaderCell>
|
||||
<TableHeaderCell className="w-8" />
|
||||
</TableHeaderRow>
|
||||
<TableScrollArea
|
||||
header={
|
||||
<TableHeaderRow className="pr-8 md:pr-8">
|
||||
<TableStickyCell header>
|
||||
{loading ? (
|
||||
<SkeletonDot />
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allReviewsSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someReviewsSelected;
|
||||
}}
|
||||
onChange={() => {
|
||||
if (allReviewsSelected) setSelectedReviewIds([]);
|
||||
else
|
||||
setSelectedReviewIds(
|
||||
filteredReviews.map((r) => r.id),
|
||||
);
|
||||
}}
|
||||
className={TABLE_CHECKBOX_CLASS}
|
||||
/>
|
||||
)}
|
||||
<span>Name</span>
|
||||
</TableStickyCell>
|
||||
<TableHeaderCell className="ml-auto w-24">Columns</TableHeaderCell>
|
||||
<TableHeaderCell className="w-24">Documents</TableHeaderCell>
|
||||
<TableHeaderCell className="w-32">Created</TableHeaderCell>
|
||||
<TableHeaderCell className="w-8" />
|
||||
</TableHeaderRow>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<ProjectReviewsLoadingRows />
|
||||
) : reviews.length === 0 ? (
|
||||
|
|
@ -122,28 +117,22 @@ export function ProjectReviewsTable({
|
|||
rightClickDropdown={(close) => (
|
||||
<RowActionMenuItems
|
||||
onClose={close}
|
||||
onRename={() => {
|
||||
onEditDetails={() => {
|
||||
if (
|
||||
currentUserId &&
|
||||
review.user_id !== currentUserId
|
||||
) {
|
||||
onOwnerOnlyAction(
|
||||
"rename this tabular review",
|
||||
"edit tabular review details",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setRenameReviewValue(
|
||||
review.title ?? "Untitled Review",
|
||||
);
|
||||
setRenamingReviewId(review.id);
|
||||
onOpenDetails(review);
|
||||
}}
|
||||
onDelete={() => onDeleteReview(review)}
|
||||
/>
|
||||
)}
|
||||
onClick={() => {
|
||||
if (renamingReviewId === review.id) return;
|
||||
onOpenReview(review.id);
|
||||
}}
|
||||
onClick={() => onOpenReview(review.id)}
|
||||
className="pr-8 md:pr-8"
|
||||
>
|
||||
<TablePrimaryCell
|
||||
|
|
@ -163,13 +152,6 @@ export function ProjectReviewsTable({
|
|||
)
|
||||
}
|
||||
label={review.title ?? "Untitled Review"}
|
||||
editing={renamingReviewId === review.id}
|
||||
editValue={renameReviewValue}
|
||||
onEditValueChange={setRenameReviewValue}
|
||||
onEditCommit={() =>
|
||||
void submitReviewRename(review.id)
|
||||
}
|
||||
onEditCancel={() => setRenamingReviewId(null)}
|
||||
/>
|
||||
<TableCell className="ml-auto w-24">
|
||||
{review.columns_config?.length ?? 0}
|
||||
|
|
@ -189,20 +171,17 @@ export function ProjectReviewsTable({
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<RowActions
|
||||
onRename={() => {
|
||||
onEditDetails={() => {
|
||||
if (
|
||||
currentUserId &&
|
||||
review.user_id !== currentUserId
|
||||
) {
|
||||
onOwnerOnlyAction(
|
||||
"rename this tabular review",
|
||||
"edit tabular review details",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setRenameReviewValue(
|
||||
review.title ?? "Untitled Review",
|
||||
);
|
||||
setRenamingReviewId(review.id);
|
||||
onOpenDetails(review);
|
||||
}}
|
||||
onDelete={() => onDeleteReview(review)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -29,10 +29,10 @@ import type {
|
|||
TabularReview,
|
||||
} from "@/app/components/shared/types";
|
||||
import { TableToolbar } from "@/app/components/shared/TableToolbar";
|
||||
import { AddNewTRModal } from "@/app/components/tabular/AddNewTRModal";
|
||||
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
|
||||
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
|
||||
import { PeopleModal } from "@/app/components/shared/PeopleModal";
|
||||
import { NewTRModal } from "@/app/components/tabular/NewTRModal";
|
||||
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";
|
||||
|
|
@ -305,6 +305,7 @@ export function ProjectWorkspaceProvider({
|
|||
async function handleProjectDetailsSave(values: {
|
||||
name: string;
|
||||
cmNumber: string;
|
||||
practice: string;
|
||||
}) {
|
||||
if (project && project.is_owner === false) {
|
||||
setOwnerOnlyAction("edit project details");
|
||||
|
|
@ -312,10 +313,12 @@ export function ProjectWorkspaceProvider({
|
|||
}
|
||||
const name = values.name.trim();
|
||||
const cmNumber = values.cmNumber.trim();
|
||||
const practice = values.practice.trim();
|
||||
if (!name) return;
|
||||
const updated = await updateProject(projectId, {
|
||||
name,
|
||||
cm_number: cmNumber,
|
||||
practice: practice || null,
|
||||
});
|
||||
setProject((prev) =>
|
||||
prev
|
||||
|
|
@ -323,6 +326,7 @@ export function ProjectWorkspaceProvider({
|
|||
...prev,
|
||||
name: updated.name,
|
||||
cm_number: updated.cm_number,
|
||||
practice: updated.practice,
|
||||
}
|
||||
: updated,
|
||||
);
|
||||
|
|
@ -417,7 +421,6 @@ export function ProjectWorkspaceProvider({
|
|||
docsCount={project?.documents?.length ?? 0}
|
||||
isOwner={project?.is_owner !== false}
|
||||
onBackToProjects={() => router.push("/projects")}
|
||||
onOwnerOnly={setOwnerOnlyAction}
|
||||
onOpenDetails={() => setProjectDetailsOpen(true)}
|
||||
onDeleteProject={requestProjectDelete}
|
||||
onSearchChange={setSearch}
|
||||
|
|
@ -428,7 +431,7 @@ export function ProjectWorkspaceProvider({
|
|||
|
||||
{children}
|
||||
|
||||
<AddNewTRModal
|
||||
<NewTRModal
|
||||
open={newTRModalOpen}
|
||||
onClose={() => setNewTRModalOpen(false)}
|
||||
onAdd={handleCreateReview}
|
||||
|
|
@ -439,7 +442,7 @@ export function ProjectWorkspaceProvider({
|
|||
projectCmNumber={project?.cm_number}
|
||||
/>
|
||||
|
||||
<OwnerOnlyModal
|
||||
<OwnerOnlyPopup
|
||||
open={!!ownerOnlyAction}
|
||||
action={ownerOnlyAction ?? undefined}
|
||||
onClose={() => setOwnerOnlyAction(null)}
|
||||
|
|
@ -449,9 +452,6 @@ export function ProjectWorkspaceProvider({
|
|||
open={projectDetailsOpen}
|
||||
project={project}
|
||||
canEdit={project?.is_owner !== false}
|
||||
currentUserDisplayName={profile?.displayName ?? null}
|
||||
currentUserEmail={user?.email ?? null}
|
||||
fetchPeople={getProjectPeople}
|
||||
onClose={() => setProjectDetailsOpen(false)}
|
||||
onSave={handleProjectDetailsSave}
|
||||
onShareProject={() => {
|
||||
|
|
|
|||
|
|
@ -3,11 +3,16 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FolderOpen, ChevronDown } from "lucide-react";
|
||||
import { listProjects, updateProject, deleteProject } from "@/app/lib/mikeApi";
|
||||
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
|
||||
import {
|
||||
listProjects,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import type { Project } from "@/app/components/shared/types";
|
||||
import { NewProjectModal } from "./NewProjectModal";
|
||||
import { ProjectDetailsModal } from "./ProjectDetailsModal";
|
||||
import { TableToolbar } from "@/app/components/shared/TableToolbar";
|
||||
import {
|
||||
RowActionMenuItems,
|
||||
|
|
@ -54,11 +59,8 @@ export function ProjectsOverview() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [detailsProject, setDetailsProject] = useState<Project | null>(null);
|
||||
const [activeFilter, setActiveFilter] = useState<ProjectFilter>("all");
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [cmEditingId, setCmEditingId] = useState<string | null>(null);
|
||||
const [cmValue, setCmValue] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
|
@ -68,44 +70,45 @@ export function ProjectsOverview() {
|
|||
const { user, isAuthenticated, authLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) {
|
||||
setLoading(true);
|
||||
return;
|
||||
}
|
||||
if (!isAuthenticated) {
|
||||
setProjects([]);
|
||||
setLoadError(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
listProjects()
|
||||
.then((loaded) => {
|
||||
|
||||
async function loadProjects() {
|
||||
await Promise.resolve();
|
||||
if (cancelled) return;
|
||||
if (authLoading) {
|
||||
setLoading(true);
|
||||
return;
|
||||
}
|
||||
if (!isAuthenticated) {
|
||||
setProjects([]);
|
||||
setLoadError(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const loaded = await listProjects();
|
||||
if (!cancelled) setProjects(loaded);
|
||||
})
|
||||
.catch((err) => {
|
||||
} catch (err) {
|
||||
console.error("[projects] failed to load projects", err);
|
||||
if (!cancelled) {
|
||||
setProjects([]);
|
||||
setLoadError("Could not load projects.");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void loadProjects();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [authLoading, isAuthenticated, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds([]);
|
||||
}, [activeFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (
|
||||
|
|
@ -129,7 +132,8 @@ export function ProjectsOverview() {
|
|||
(p) =>
|
||||
!q ||
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
(p.cm_number ?? "").toLowerCase().includes(q),
|
||||
(p.cm_number ?? "").toLowerCase().includes(q) ||
|
||||
(p.practice ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
|
||||
const allSelected =
|
||||
|
|
@ -158,25 +162,36 @@ export function ProjectsOverview() {
|
|||
{ id: "shared-with-me", label: "Shared with me" },
|
||||
];
|
||||
|
||||
async function handleRenameSubmit(projectId: string) {
|
||||
const trimmed = renameValue.trim();
|
||||
setRenamingId(null);
|
||||
if (!trimmed) return;
|
||||
async function handleProjectDetailsSave(values: {
|
||||
name: string;
|
||||
cmNumber: string;
|
||||
practice: string;
|
||||
}) {
|
||||
if (!detailsProject) return;
|
||||
if (
|
||||
detailsProject.is_owner === false ||
|
||||
(user?.id && detailsProject.user_id !== user.id)
|
||||
) {
|
||||
setOwnerOnlyAction("edit project details");
|
||||
return;
|
||||
}
|
||||
const name = values.name.trim();
|
||||
const cmNumber = values.cmNumber.trim();
|
||||
const practice = values.practice.trim();
|
||||
if (!name) return;
|
||||
const updated = await updateProject(detailsProject.id, {
|
||||
name,
|
||||
cm_number: cmNumber,
|
||||
practice: practice || null,
|
||||
});
|
||||
setProjects((prev) =>
|
||||
prev.map((p) => (p.id === projectId ? { ...p, name: trimmed } : p)),
|
||||
);
|
||||
await updateProject(projectId, { name: trimmed });
|
||||
}
|
||||
|
||||
async function handleCmSubmit(projectId: string) {
|
||||
const trimmed = cmValue.trim();
|
||||
setCmEditingId(null);
|
||||
setProjects((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === projectId ? { ...p, cm_number: trimmed || null } : p,
|
||||
prev.map((project) =>
|
||||
project.id === updated.id ? { ...project, ...updated } : project,
|
||||
),
|
||||
);
|
||||
await updateProject(projectId, { cm_number: trimmed || undefined });
|
||||
setDetailsProject((current) =>
|
||||
current?.id === updated.id ? { ...current, ...updated } : current,
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDeleteSelected() {
|
||||
|
|
@ -253,40 +268,46 @@ export function ProjectsOverview() {
|
|||
<TableToolbar
|
||||
items={filters}
|
||||
active={activeFilter}
|
||||
onChange={setActiveFilter}
|
||||
onChange={(nextFilter) => {
|
||||
setActiveFilter(nextFilter);
|
||||
setSelectedIds([]);
|
||||
}}
|
||||
actions={toolbarActions}
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
<TableScrollArea>
|
||||
{/* Column headers */}
|
||||
<TableHeaderRow>
|
||||
<TableStickyCell header>
|
||||
{loading ? (
|
||||
<SkeletonDot />
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected;
|
||||
}}
|
||||
onChange={toggleAll}
|
||||
className={TABLE_CHECKBOX_CLASS}
|
||||
/>
|
||||
)}
|
||||
<span>Name</span>
|
||||
</TableStickyCell>
|
||||
<TableHeaderCell className="ml-auto w-32">CM</TableHeaderCell>
|
||||
<TableHeaderCell className="w-32">Owner</TableHeaderCell>
|
||||
<TableHeaderCell className="w-24">Files</TableHeaderCell>
|
||||
<TableHeaderCell className="w-24">Chats</TableHeaderCell>
|
||||
<TableHeaderCell className="w-36">
|
||||
Tabular Reviews
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="w-32">Created</TableHeaderCell>
|
||||
<TableHeaderCell className="w-8" />
|
||||
</TableHeaderRow>
|
||||
<TableScrollArea
|
||||
header={
|
||||
<TableHeaderRow>
|
||||
<TableStickyCell header>
|
||||
{loading ? (
|
||||
<SkeletonDot />
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected;
|
||||
}}
|
||||
onChange={toggleAll}
|
||||
className={TABLE_CHECKBOX_CLASS}
|
||||
/>
|
||||
)}
|
||||
<span>Name</span>
|
||||
</TableStickyCell>
|
||||
<TableHeaderCell className="ml-auto w-32">CM</TableHeaderCell>
|
||||
<TableHeaderCell className="w-36">Practice</TableHeaderCell>
|
||||
<TableHeaderCell className="w-32">Owner</TableHeaderCell>
|
||||
<TableHeaderCell className="w-24">Files</TableHeaderCell>
|
||||
<TableHeaderCell className="w-24">Chats</TableHeaderCell>
|
||||
<TableHeaderCell className="w-36">
|
||||
Tabular Reviews
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="w-32">Created</TableHeaderCell>
|
||||
<TableHeaderCell className="w-8" />
|
||||
</TableHeaderRow>
|
||||
}
|
||||
>
|
||||
|
||||
{loading ? (
|
||||
<TableBody>
|
||||
|
|
@ -305,6 +326,9 @@ export function ProjectsOverview() {
|
|||
<TableCell className="ml-auto w-32">
|
||||
<SkeletonLine className="w-20" />
|
||||
</TableCell>
|
||||
<TableCell className="w-36">
|
||||
<SkeletonLine className="w-20" />
|
||||
</TableCell>
|
||||
<TableCell className="w-32">
|
||||
<SkeletonLine className="w-16" />
|
||||
</TableCell>
|
||||
|
|
@ -375,20 +399,8 @@ export function ProjectsOverview() {
|
|||
? (close) => (
|
||||
<RowActionMenuItems
|
||||
onClose={close}
|
||||
onRename={() => {
|
||||
setRenameValue(
|
||||
project.name,
|
||||
);
|
||||
setRenamingId(project.id);
|
||||
}}
|
||||
onUpdateCmNumber={() => {
|
||||
setCmValue(
|
||||
project.cm_number ??
|
||||
"",
|
||||
);
|
||||
setCmEditingId(
|
||||
project.id,
|
||||
);
|
||||
onEditDetails={() => {
|
||||
setDetailsProject(project);
|
||||
}}
|
||||
onDelete={async () => {
|
||||
await deleteProject(
|
||||
|
|
@ -407,7 +419,6 @@ export function ProjectsOverview() {
|
|||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (renamingId === project.id) return;
|
||||
router.push(`/projects/${project.id}`);
|
||||
}}
|
||||
>
|
||||
|
|
@ -419,44 +430,20 @@ export function ProjectsOverview() {
|
|||
toggleOne(project.id)
|
||||
}
|
||||
label={project.name}
|
||||
editing={renamingId === project.id}
|
||||
editValue={renameValue}
|
||||
onEditValueChange={setRenameValue}
|
||||
onEditCommit={() =>
|
||||
handleRenameSubmit(project.id)
|
||||
}
|
||||
onEditCancel={() => setRenamingId(null)}
|
||||
/>
|
||||
|
||||
<TableCell
|
||||
className="ml-auto w-32"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{cmEditingId === project.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={cmValue}
|
||||
onChange={(e) =>
|
||||
setCmValue(e.target.value)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter")
|
||||
handleCmSubmit(project.id);
|
||||
if (e.key === "Escape")
|
||||
setCmEditingId(null);
|
||||
}}
|
||||
onBlur={() =>
|
||||
handleCmSubmit(project.id)
|
||||
}
|
||||
placeholder="CM #"
|
||||
className="w-full text-sm text-gray-800 bg-transparent outline-none"
|
||||
/>
|
||||
) : (
|
||||
(project.cm_number ?? (
|
||||
<span className="text-gray-300">
|
||||
—
|
||||
</span>
|
||||
))
|
||||
<TableCell className="ml-auto w-32">
|
||||
{project.cm_number ?? (
|
||||
<span className="text-gray-300">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="w-36">
|
||||
{project.practice ?? (
|
||||
<span className="text-gray-300">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="w-32">
|
||||
|
|
@ -482,15 +469,8 @@ export function ProjectsOverview() {
|
|||
{(project.is_owner ??
|
||||
project.user_id === user?.id) && (
|
||||
<RowActions
|
||||
onRename={() => {
|
||||
setRenameValue(project.name);
|
||||
setRenamingId(project.id);
|
||||
}}
|
||||
onUpdateCmNumber={() => {
|
||||
setCmValue(
|
||||
project.cm_number ?? "",
|
||||
);
|
||||
setCmEditingId(project.id);
|
||||
onEditDetails={() => {
|
||||
setDetailsProject(project);
|
||||
}}
|
||||
onDelete={async () => {
|
||||
await deleteProject(project.id);
|
||||
|
|
@ -520,7 +500,19 @@ export function ProjectsOverview() {
|
|||
}}
|
||||
/>
|
||||
|
||||
<OwnerOnlyModal
|
||||
<ProjectDetailsModal
|
||||
open={!!detailsProject}
|
||||
project={detailsProject}
|
||||
canEdit={
|
||||
!!detailsProject &&
|
||||
detailsProject.is_owner !== false &&
|
||||
(!user?.id || detailsProject.user_id === user.id)
|
||||
}
|
||||
onClose={() => setDetailsProject(null)}
|
||||
onSave={handleProjectDetailsSave}
|
||||
/>
|
||||
|
||||
<OwnerOnlyPopup
|
||||
open={!!ownerOnlyAction}
|
||||
action={ownerOnlyAction ?? undefined}
|
||||
onClose={() => setOwnerOnlyAction(null)}
|
||||
|
|
|
|||
|
|
@ -1,316 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AlertCircle, Upload, Search, Loader2, X } from "lucide-react";
|
||||
import {
|
||||
uploadStandaloneDocument,
|
||||
uploadProjectDocument,
|
||||
addDocumentToProject,
|
||||
deleteDocument,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import type { Document } from "./types";
|
||||
import { FileDirectory } from "./FileDirectory";
|
||||
import { useDirectoryData, invalidateDirectoryCache } from "./useDirectoryData";
|
||||
import { OwnerOnlyModal } from "./OwnerOnlyModal";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
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 { user } = useAuth();
|
||||
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 [search, setSearch] = useState("");
|
||||
const [extraUploadedDocs, setExtraUploadedDocs] = useState<Document[]>([]);
|
||||
// IDs deleted in this session — hidden locally since `useDirectoryData`'s
|
||||
// cached state won't re-fetch until the modal reopens.
|
||||
const [deletedIds, setDeletedIds] = useState<Set<string>>(new Set());
|
||||
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSearch("");
|
||||
setSelectedIds(new Set());
|
||||
setExtraUploadedDocs([]);
|
||||
setDeletedIds(new Set());
|
||||
setUploadingFilenames([]);
|
||||
setUploadWarning(null);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const q = search.toLowerCase().trim();
|
||||
|
||||
const allStandalone = [
|
||||
...extraUploadedDocs.filter(
|
||||
(u) => !standaloneDocuments.some((d) => d.id === u.id),
|
||||
),
|
||||
...standaloneDocuments,
|
||||
].filter((d) => !deletedIds.has(d.id));
|
||||
|
||||
const filteredStandalone = q
|
||||
? allStandalone.filter((d) =>
|
||||
d.filename.toLowerCase().includes(q),
|
||||
)
|
||||
: allStandalone;
|
||||
|
||||
const filteredProjects = projects
|
||||
.filter((p) => p.id !== projectId)
|
||||
.map((p) => ({
|
||||
...p,
|
||||
documents: (p.documents || []).filter(
|
||||
(d) =>
|
||||
!deletedIds.has(d.id) &&
|
||||
(!q ||
|
||||
d.filename.toLowerCase().includes(q)),
|
||||
),
|
||||
}))
|
||||
.filter(
|
||||
(p) =>
|
||||
!q ||
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.documents.length > 0,
|
||||
);
|
||||
|
||||
const allDocs = [
|
||||
...allStandalone,
|
||||
...projects.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 handleDelete(ids: string[]) {
|
||||
// Server only allows the doc creator to delete. Filter to owned
|
||||
// and warn for the rest.
|
||||
const docsById = new Map<string, Document>();
|
||||
for (const d of [
|
||||
...standaloneDocuments,
|
||||
...extraUploadedDocs,
|
||||
...projects.flatMap((p) => p.documents ?? []),
|
||||
]) {
|
||||
docsById.set(d.id, d);
|
||||
}
|
||||
const owned = ids.filter((id) => {
|
||||
const d = docsById.get(id);
|
||||
return !d || !d.user_id || !user?.id || d.user_id === user.id;
|
||||
});
|
||||
const blocked = ids.length - owned.length;
|
||||
if (owned.length === 0 && blocked > 0) {
|
||||
setOwnerOnlyAction(
|
||||
"delete these documents — only the document creator can delete a document",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const idSet = new Set(owned);
|
||||
try {
|
||||
await Promise.all(owned.map((id) => deleteDocument(id)));
|
||||
} catch (err) {
|
||||
console.error("Delete failed:", err);
|
||||
return;
|
||||
}
|
||||
invalidateDirectoryCache();
|
||||
setExtraUploadedDocs((prev) => prev.filter((d) => !idSet.has(d.id)));
|
||||
setDeletedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
owned.forEach((id) => next.add(id));
|
||||
return next;
|
||||
});
|
||||
if (blocked > 0) {
|
||||
setOwnerOnlyAction(
|
||||
`delete ${blocked} of the selected documents — only the document creator can delete a document`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
{/* Search bar */}
|
||||
<div className="pt-1 pb-2">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
|
||||
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 bg-transparent text-sm text-gray-700 placeholder:text-gray-400 outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch("")}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* File browser */}
|
||||
<FileDirectory
|
||||
standaloneDocs={filteredStandalone}
|
||||
directoryProjects={filteredProjects}
|
||||
loading={loading}
|
||||
selectedIds={selectedIds}
|
||||
onChange={setSelectedIds}
|
||||
allowMultiple={allowMultiple}
|
||||
forceExpanded={!!q}
|
||||
emptyMessage={q ? "No matches found" : "No documents yet"}
|
||||
onDelete={handleDelete}
|
||||
uploadingFilenames={uploadingFilenames}
|
||||
/>
|
||||
</Modal>
|
||||
<OwnerOnlyModal
|
||||
open={!!ownerOnlyAction}
|
||||
action={ownerOnlyAction ?? undefined}
|
||||
onClose={() => setOwnerOnlyAction(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,261 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, Loader2, Search, Upload, X } from "lucide-react";
|
||||
import { getProject, uploadProjectDocument } from "@/app/lib/mikeApi";
|
||||
import type { Document } from "./types";
|
||||
import { DocFileIcon } from "./FileDirectory";
|
||||
import { VersionChip } from "./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"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
{/* Search */}
|
||||
<div className="pt-1 pb-2">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
|
||||
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 bg-transparent text-sm text-gray-700 placeholder:text-gray-400 outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch("")}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
{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-200 animate-pulse shrink-0" />
|
||||
<div
|
||||
className="h-3 rounded bg-gray-200 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 px-2 py-2 text-xs text-left transition-colors ${
|
||||
excluded
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: checked
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { providerLabel, type ModelProvider } from "@/app/lib/modelAvailability";
|
||||
import { WarningPopup } from "./WarningPopup";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
provider: ModelProvider | null;
|
||||
/** Optional override for the body sentence. */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function ApiKeyMissingModal({ 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -126,7 +126,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
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_-2px_7px_rgba(15,23,42,0.044),0_5px_12px_rgba(15,23,42,0.095),inset_0_1px_0_rgba(255,255,255,0.85)] backdrop-blur-2xl overflow-visible",
|
||||
"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]",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:text-gray-950"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={resolvedConfirmDisabled}
|
||||
className={cn(
|
||||
"inline-flex h-7 items-center justify-center rounded-full px-3.5 text-xs font-medium leading-none text-white backdrop-blur-xl transition-all active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100",
|
||||
isDeleteAction
|
||||
? "border border-red-700/35 bg-red-600/90 shadow-[0_3px_9px_rgba(127,29,29,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(127,29,29,0.18)] hover:bg-red-600"
|
||||
: "border border-gray-700/40 bg-gray-950/88 shadow-[0_3px_9px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(15,23,42,0.2)] hover:bg-gray-900/90",
|
||||
)}
|
||||
aria-busy={confirmBusy}
|
||||
>
|
||||
{resolvedConfirmLabel}
|
||||
</button>
|
||||
</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`;
|
||||
}
|
||||
|
|
@ -1,559 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { applyOptimisticResolution } from "../assistant/EditCard";
|
||||
import { DocView } from "./DocView";
|
||||
import { DocxView } from "./DocxView";
|
||||
import {
|
||||
RelevantQuotes,
|
||||
type RelevantQuoteItem,
|
||||
} from "./RelevantQuotes";
|
||||
import {
|
||||
expandCitationToEntries,
|
||||
formatCitationPage,
|
||||
getDocumentCitationQuotes,
|
||||
} from "./types";
|
||||
import type {
|
||||
CitationQuote,
|
||||
CitationAnnotation,
|
||||
DocumentCitationAnnotation,
|
||||
EditAnnotation,
|
||||
} from "./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: CitationAnnotation }
|
||||
| {
|
||||
kind: "edit";
|
||||
edit: EditAnnotation;
|
||||
/**
|
||||
* 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 citationQuoteId =
|
||||
mode.kind === "citation" ? `document:${mode.citation.ref}:0` : null;
|
||||
const [activeCitationQuoteId, setActiveCitationQuoteId] = useState<
|
||||
string | null
|
||||
>(citationQuoteId);
|
||||
const [quoteFocusKey, setQuoteFocusKey] = 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 DocumentCitationAnnotation;
|
||||
return expandCitationToEntries({
|
||||
...documentCitation,
|
||||
page: selectedQuote.page,
|
||||
quote: selectedQuote.quote,
|
||||
quotes: [selectedQuote],
|
||||
});
|
||||
}, [activeCitationQuoteId, citationQuoteId, 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}`,
|
||||
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,
|
||||
};
|
||||
}, [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 mode={mode} />}
|
||||
|
||||
<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}
|
||||
/>
|
||||
) : (
|
||||
<DocView
|
||||
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 SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-xs font-medium text-gray-700">{children}</p>;
|
||||
}
|
||||
|
||||
function RelevantQuoteSection({
|
||||
citation,
|
||||
filename,
|
||||
activeQuoteId,
|
||||
onQuoteSelect,
|
||||
}: {
|
||||
citation: CitationAnnotation;
|
||||
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: RelevantQuoteItem[] = citationQuotes.map(
|
||||
(quote, index) => {
|
||||
const pageLabel = `Page ${quote.page}`;
|
||||
return {
|
||||
id: `document:${citation.ref}:${index}`,
|
||||
quote: quote.quote.replaceAll("[[PAGE_BREAK]]", "..."),
|
||||
inlineDetail: pageLabel,
|
||||
citationText: [filename, pageLabel].filter(Boolean).join(", "),
|
||||
};
|
||||
},
|
||||
);
|
||||
const currentIndex = Math.max(
|
||||
0,
|
||||
relevantQuotes.findIndex((quote) => quote.id === activeQuoteId),
|
||||
);
|
||||
|
||||
return (
|
||||
<RelevantQuotes
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackedChangeHeader({
|
||||
mode,
|
||||
}: {
|
||||
mode: Extract<DocPanelMode, { kind: "edit" }>;
|
||||
}) {
|
||||
const { edit, isEditReloading, onResolveStart, onResolved, onError } = mode;
|
||||
return (
|
||||
<div className="px-3 pb-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<SectionLabel>Tracked Change</SectionLabel>
|
||||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||
<EditResolveButtons
|
||||
edit={edit}
|
||||
isReloading={isEditReloading}
|
||||
onResolveStart={onResolveStart}
|
||||
onResolved={onResolved}
|
||||
onError={onError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{edit.reason && (
|
||||
<p className="mb-2 text-xs text-gray-500">{edit.reason}</p>
|
||||
)}
|
||||
<div className="w-full rounded-md bg-gray-50 border border-gray-200 px-2 py-2">
|
||||
<div className="text-sm leading-relaxed font-serif">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accept / Reject controls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EditResolveButtons({
|
||||
edit,
|
||||
isReloading,
|
||||
onResolveStart,
|
||||
onResolved,
|
||||
onError,
|
||||
}: {
|
||||
edit: EditAnnotation;
|
||||
/**
|
||||
* True while an accept/reject for any edit on this document is in
|
||||
* flight (triggered from here, the inline EditCard, the bulk bar, or
|
||||
* elsewhere). Disables both buttons so the user can't double-submit
|
||||
* while a resolution is racing to change the status.
|
||||
*/
|
||||
isReloading?: 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;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState<"pending" | "accepted" | "rejected">(
|
||||
edit.status,
|
||||
);
|
||||
// Sync with the prop when this edit is resolved elsewhere (bulk
|
||||
// accept/reject, inline per-edit card, another open panel for the same
|
||||
// edit). Skips while our own request is in flight so we don't flicker.
|
||||
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,
|
||||
});
|
||||
// Optimistically mutate the DOM in the open viewer so the
|
||||
// change reflects immediately. Revert if the backend errors.
|
||||
let revert: (() => void) | null = null;
|
||||
try {
|
||||
revert = applyOptimisticResolution(edit, verb);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"[DocPanel] 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("[DocPanel] resolve failed", e);
|
||||
try {
|
||||
revert?.();
|
||||
} catch (revertErr) {
|
||||
console.error(
|
||||
"[DocPanel] 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">
|
||||
<button
|
||||
onClick={() => handle("accept")}
|
||||
disabled={inFlight || resolved}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-900 bg-gray-900 px-2 py-1.5 text-xs font-medium text-white hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{status === "accepted" ? "Accepted" : "Accept"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handle("reject")}
|
||||
disabled={inFlight || resolved}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{status === "rejected" ? "Rejected" : "Reject"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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,601 +0,0 @@
|
|||
"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;
|
||||
bordered?: 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 DocView({
|
||||
doc,
|
||||
quotes,
|
||||
quoteFocusKey,
|
||||
quote,
|
||||
fallbackPage,
|
||||
rounded = true,
|
||||
bordered = 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}
|
||||
bordered={bordered}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-col flex-1 overflow-hidden ${bordered ? "border border-gray-200" : ""} ${rounded ? "rounded-lg" : ""}`}
|
||||
>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-auto bg-gray-100 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Download, Trash2, X } from "lucide-react";
|
||||
import { DocView } from "./DocView";
|
||||
import { getDocumentUrl } from "@/app/lib/mikeApi";
|
||||
import type { Document } from "./types";
|
||||
|
||||
interface Props {
|
||||
doc: Document | null;
|
||||
/** Optional specific version to display. Only honoured for DOCX. */
|
||||
versionId?: string | null;
|
||||
/** Optional label suffix for the header (e.g. "V3"). */
|
||||
versionLabel?: string | null;
|
||||
onClose: () => void;
|
||||
onDelete?: (doc: Document) => void;
|
||||
}
|
||||
|
||||
export function DocViewModal({
|
||||
doc,
|
||||
versionId,
|
||||
versionLabel,
|
||||
onClose,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
if (!doc || !mounted) return null;
|
||||
|
||||
async function handleDownload() {
|
||||
if (!doc) return;
|
||||
const { url, filename } = await getDocumentUrl(doc.id, versionId ?? null);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-100 flex items-center justify-center bg-black/40"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative flex flex-col bg-white rounded-xl shadow-2xl w-[800px] max-w-[90vw] h-[90vh]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 shrink-0">
|
||||
<span className="text-base font-medium font-serif text-gray-800 truncate pr-4">
|
||||
{doc.filename}
|
||||
{versionLabel && (
|
||||
<span className="ml-2 text-xs font-normal text-gray-500">
|
||||
{versionLabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="flex items-center justify-center w-6 h-6 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => { onDelete(doc); onClose(); }}
|
||||
className="flex items-center justify-center w-6 h-6 rounded hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center justify-center w-6 h-6 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DocView serves PDF when available and falls back to
|
||||
docx-preview internally if the active version has no
|
||||
PDF rendition. Passing no versionId tells the backend
|
||||
to resolve the latest tracked-changes version. */}
|
||||
<div className="flex flex-col flex-1 overflow-hidden px-3 pb-3">
|
||||
<DocView
|
||||
key={versionId ?? "current"}
|
||||
doc={{
|
||||
document_id: doc.id,
|
||||
version_id: versionId ?? null,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { FileText, File, X, AlertCircle, Loader2 } from "lucide-react";
|
||||
import type { Document } from "./types";
|
||||
|
||||
interface Props {
|
||||
document: Document;
|
||||
onRemove?: (id: string) => void;
|
||||
onClick?: (doc: Document) => void;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
function FileIcon({ fileType }: { fileType: string | null }) {
|
||||
if (fileType === "pdf") {
|
||||
return <FileText className="h-4 w-4 text-red-600 shrink-0" />;
|
||||
}
|
||||
if (fileType === "docx" || fileType === "doc") {
|
||||
return <File className="h-4 w-4 text-blue-600 shrink-0" />;
|
||||
}
|
||||
return <File className="h-4 w-4 text-gray-500 shrink-0" />;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function DocumentCard({ document, onRemove, onClick, selected }: Props) {
|
||||
const isError = document.status === "error";
|
||||
const isProcessing = document.status === "pending" || document.status === "processing";
|
||||
const filename = document.filename;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(document)}
|
||||
className={[
|
||||
"flex items-center gap-2.5 rounded-lg border px-3 py-2.5 text-sm transition-colors",
|
||||
onClick ? "cursor-pointer" : "",
|
||||
selected
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: isError
|
||||
? "border-red-200 bg-red-50"
|
||||
: "border-gray-200 bg-white hover:border-gray-300",
|
||||
].join(" ")}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" />
|
||||
) : isError ? (
|
||||
<AlertCircle className="h-4 w-4 text-red-500 shrink-0" />
|
||||
) : (
|
||||
<FileIcon fileType={document.file_type} />
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-gray-800" title={filename}>
|
||||
{filename}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{isProcessing
|
||||
? "Processing…"
|
||||
: isError
|
||||
? "Upload failed"
|
||||
: [
|
||||
document.size_bytes != null ? formatBytes(document.size_bytes) : null,
|
||||
document.page_count ? `${document.page_count}p` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{onRemove && !isProcessing && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(document.id);
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
aria-label="Remove document"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,505 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useFetchDocxBytes } from "@/app/hooks/useFetchDocxBytes";
|
||||
import { supabase } from "@/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;
|
||||
bordered?: 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,
|
||||
bordered = 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 ${bordered ? "border border-gray-200" : ""} ${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 bg-gray-100 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
interface Props {
|
||||
emails: string[];
|
||||
onChange: (emails: string[]) => void;
|
||||
validate?: (email: string) => Promise<string | null>;
|
||||
onValidatingChange?: (validating: boolean) => void;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export function EmailPillInput({
|
||||
emails,
|
||||
onChange,
|
||||
validate,
|
||||
onValidatingChange,
|
||||
placeholder = "Add by email…",
|
||||
autoFocus = false,
|
||||
}: Props) {
|
||||
const [input, setInput] = useState("");
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function setValidatingState(v: boolean) {
|
||||
setValidating(v);
|
||||
onValidatingChange?.(v);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
addEmail();
|
||||
} else if (e.key === "Backspace" && !input && emails.length > 0) {
|
||||
onChange(emails.slice(0, -1));
|
||||
}
|
||||
}
|
||||
|
||||
async function addEmail() {
|
||||
const email = input.trim().toLowerCase();
|
||||
if (!email) return;
|
||||
if (emails.includes(email)) {
|
||||
setInput("");
|
||||
return;
|
||||
}
|
||||
if (!EMAIL_RE.test(email)) {
|
||||
setError("Enter a valid email address.");
|
||||
return;
|
||||
}
|
||||
if (validate) {
|
||||
setValidatingState(true);
|
||||
setError(null);
|
||||
try {
|
||||
const err = await validate(email);
|
||||
if (err) {
|
||||
setError(err);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setError("Could not verify email. Try again.");
|
||||
return;
|
||||
} finally {
|
||||
setValidatingState(false);
|
||||
}
|
||||
}
|
||||
onChange([...emails, email]);
|
||||
setInput("");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={`flex flex-wrap gap-1.5 rounded-lg border bg-gray-50 px-3 py-2 min-h-[40px] transition-colors ${
|
||||
error
|
||||
? "border-red-300 focus-within:border-red-400"
|
||||
: "border-gray-200 focus-within:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
{emails.map((email) => (
|
||||
<span
|
||||
key={email}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-gray-200 px-2.5 py-0.5 text-xs text-gray-700"
|
||||
>
|
||||
{email}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(emails.filter((e) => e !== email))}
|
||||
className="text-gray-400 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="email"
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={addEmail}
|
||||
placeholder={emails.length === 0 ? placeholder : ""}
|
||||
className="flex-1 min-w-[160px] bg-transparent text-sm text-gray-700 placeholder:text-gray-400 outline-none"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="mt-1.5 text-xs text-red-500">{error}</p>}
|
||||
{validating && <p className="mt-1.5 text-xs text-gray-400">Checking…</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,14 +5,15 @@ import {
|
|||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
File,
|
||||
FileText,
|
||||
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";
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
if (!iso) return null;
|
||||
|
|
@ -24,9 +25,7 @@ function formatDate(iso: string | null) {
|
|||
}
|
||||
|
||||
export function DocFileIcon({ fileType }: { fileType: string | null }) {
|
||||
if (fileType === "pdf")
|
||||
return <FileText className="h-3.5 w-3.5 text-red-500 shrink-0" />;
|
||||
return <File className="h-3.5 w-3.5 text-blue-500 shrink-0" />;
|
||||
return <FileTypeIcon fileType={fileType} className="h-3.5 w-3.5" />;
|
||||
}
|
||||
|
||||
interface FileDirectoryProps {
|
||||
|
|
@ -41,6 +40,10 @@ interface FileDirectoryProps {
|
|||
heading?: string;
|
||||
onDelete?: (ids: string[]) => void | Promise<void>;
|
||||
uploadingFilenames?: string[];
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
searchAutoFocus?: boolean;
|
||||
searchNoResultsMessage?: string;
|
||||
}
|
||||
|
||||
export function FileDirectory({
|
||||
|
|
@ -55,13 +58,52 @@ export function FileDirectory({
|
|||
heading = "Documents",
|
||||
onDelete,
|
||||
uploadingFilenames = [],
|
||||
searchable = false,
|
||||
searchPlaceholder = "Search...",
|
||||
searchAutoFocus = false,
|
||||
searchNoResultsMessage = "No matches found",
|
||||
}: FileDirectoryProps) {
|
||||
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const selectedCount = selectedIds.size;
|
||||
const q = search.trim().toLowerCase();
|
||||
const visibleStandaloneDocs = q
|
||||
? standaloneDocs.filter((doc) => doc.filename.toLowerCase().includes(q))
|
||||
: standaloneDocs;
|
||||
const visibleUploadingFilenames = q
|
||||
? uploadingFilenames.filter((filename) =>
|
||||
filename.toLowerCase().includes(q),
|
||||
)
|
||||
: uploadingFilenames;
|
||||
const visibleDirectoryProjects = q
|
||||
? directoryProjects
|
||||
.map((project) => {
|
||||
const docs = project.documents ?? [];
|
||||
const projectMatches =
|
||||
project.name.toLowerCase().includes(q) ||
|
||||
(project.cm_number ?? "").toLowerCase().includes(q);
|
||||
return {
|
||||
...project,
|
||||
documents: projectMatches
|
||||
? docs
|
||||
: docs.filter((doc) =>
|
||||
doc.filename.toLowerCase().includes(q),
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((project) => {
|
||||
const docs = project.documents ?? [];
|
||||
return (
|
||||
docs.length > 0 ||
|
||||
project.name.toLowerCase().includes(q) ||
|
||||
(project.cm_number ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
: directoryProjects;
|
||||
|
||||
async function handleDelete() {
|
||||
if (!onDelete || selectedCount === 0 || deleting) return;
|
||||
|
|
@ -83,8 +125,8 @@ export function FileDirectory({
|
|||
];
|
||||
|
||||
const allStandaloneSelected =
|
||||
standaloneDocs.length > 0 &&
|
||||
standaloneDocs.every((d) => selectedIds.has(d.id));
|
||||
visibleStandaloneDocs.length > 0 &&
|
||||
visibleStandaloneDocs.every((d) => selectedIds.has(d.id));
|
||||
|
||||
function toggle(docId: string) {
|
||||
if (!allowMultiple) {
|
||||
|
|
@ -92,18 +134,22 @@ export function FileDirectory({
|
|||
return;
|
||||
}
|
||||
const next = new Set(selectedIds);
|
||||
next.has(docId) ? next.delete(docId) : next.add(docId);
|
||||
if (next.has(docId)) {
|
||||
next.delete(docId);
|
||||
} else {
|
||||
next.add(docId);
|
||||
}
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
if (allStandaloneSelected) {
|
||||
const next = new Set(selectedIds);
|
||||
standaloneDocs.forEach((d) => next.delete(d.id));
|
||||
visibleStandaloneDocs.forEach((d) => next.delete(d.id));
|
||||
onChange(next);
|
||||
} else {
|
||||
const next = new Set(selectedIds);
|
||||
standaloneDocs.forEach((d) => next.add(d.id));
|
||||
visibleStandaloneDocs.forEach((d) => next.add(d.id));
|
||||
onChange(next);
|
||||
}
|
||||
}
|
||||
|
|
@ -112,30 +158,43 @@ export function FileDirectory({
|
|||
if (forceExpanded) return;
|
||||
setExpandedProjects((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(projectId) ? next.delete(projectId) : next.add(projectId);
|
||||
if (next.has(projectId)) {
|
||||
next.delete(projectId);
|
||||
} else {
|
||||
next.add(projectId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-sm border border-gray-100 overflow-hidden">
|
||||
<div className="space-y-px">
|
||||
{searchable && (
|
||||
<SearchBar
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={searchPlaceholder}
|
||||
autoFocus={searchAutoFocus}
|
||||
wrapperClassName="mb-2"
|
||||
/>
|
||||
)}
|
||||
{/* Documents header skeleton */}
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<div className="h-3 w-20 rounded bg-gray-200 animate-pulse" />
|
||||
<div className="h-3 w-12 rounded bg-gray-200 animate-pulse" />
|
||||
<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>
|
||||
{[60, 45, 75, 55, 40].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 px-2 py-2"
|
||||
className="flex items-center gap-2 rounded-md 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-200 animate-pulse 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-200 animate-pulse"
|
||||
className="h-3 rounded bg-gray-100 animate-pulse"
|
||||
style={{ width: `${w}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -151,215 +210,255 @@ export function FileDirectory({
|
|||
uploadingFilenames.length === 0
|
||||
) {
|
||||
return (
|
||||
<p className="text-center text-sm text-gray-400 py-8">
|
||||
{emptyMessage}
|
||||
</p>
|
||||
<div>
|
||||
{searchable && (
|
||||
<SearchBar
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={searchPlaceholder}
|
||||
autoFocus={searchAutoFocus}
|
||||
wrapperClassName="mb-2"
|
||||
/>
|
||||
)}
|
||||
<p className="text-center text-sm text-gray-400 py-8">
|
||||
{emptyMessage}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-sm border border-gray-100 overflow-hidden">
|
||||
<div>
|
||||
{(standaloneDocs.length > 0 ||
|
||||
uploadingFilenames.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>
|
||||
)}
|
||||
{standaloneDocs.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>
|
||||
)}
|
||||
{uploadingFilenames.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>
|
||||
))}
|
||||
{standaloneDocs.map((doc) => {
|
||||
const selected = selectedIds.has(doc.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={doc.id}
|
||||
onClick={() => toggle(doc.id)}
|
||||
className={`w-full flex items-center gap-2 px-2 py-2 text-xs transition-colors text-left ${
|
||||
selected ? "bg-gray-100" : "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<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" />
|
||||
<div className="space-y-2 rounded-sm">
|
||||
{searchable && (
|
||||
<SearchBar
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={searchPlaceholder}
|
||||
autoFocus={searchAutoFocus}
|
||||
/>
|
||||
)}
|
||||
{q &&
|
||||
visibleStandaloneDocs.length === 0 &&
|
||||
visibleDirectoryProjects.length === 0 &&
|
||||
visibleUploadingFilenames.length === 0 ? (
|
||||
<p className="text-center text-sm text-gray-400 py-8">
|
||||
{searchNoResultsMessage}
|
||||
</p>
|
||||
) : (
|
||||
<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>
|
||||
<DocFileIcon fileType={doc.file_type} />
|
||||
<span
|
||||
className={`flex-1 truncate ${
|
||||
selected ? "text-gray-900" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{doc.filename}
|
||||
<span className="shrink-0 text-gray-300">
|
||||
Uploading
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
{standaloneDocs.length > 0 && directoryProjects.length > 0 && (
|
||||
<div className="border-t border-gray-100 py-2 px-2">
|
||||
<p className="text-xs font-medium text-gray-400">
|
||||
Projects
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{directoryProjects.map((project) => {
|
||||
const isExpanded =
|
||||
forceExpanded || expandedProjects.has(project.id);
|
||||
const docs = project.documents ?? [];
|
||||
return (
|
||||
<div key={project.id}>
|
||||
</div>
|
||||
))}
|
||||
{visibleStandaloneDocs.map((doc) => {
|
||||
const selected = selectedIds.has(doc.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFolder(project.id)}
|
||||
className="w-full flex items-center gap-2 px-2 py-2 text-xs hover:bg-gray-50 transition-colors text-left"
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3 w-3 text-gray-400 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 text-gray-400 shrink-0" />
|
||||
)}
|
||||
<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
|
||||
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>
|
||||
<span className="text-xs text-gray-400 shrink-0">
|
||||
{docs.length}
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{docs.length === 0 ? (
|
||||
<p className="pl-7 py-1 text-xs text-gray-400">
|
||||
Empty
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
|
||||
{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" />
|
||||
) : (
|
||||
docs.map((doc) => {
|
||||
const selected = selectedIds.has(
|
||||
doc.id,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={doc.id}
|
||||
onClick={() =>
|
||||
toggle(doc.id)
|
||||
}
|
||||
className={`w-full flex items-center gap-2 pl-7 pr-2 py-2 text-xs transition-colors text-left ${
|
||||
selected
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<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 min-w-0 ${
|
||||
selected
|
||||
? "text-gray-900 font-medium"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{doc.filename}
|
||||
</span>
|
||||
<VersionChip
|
||||
n={
|
||||
doc.active_version_number ??
|
||||
doc.latest_version_number
|
||||
<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)
|
||||
}
|
||||
/>
|
||||
{doc.created_at && (
|
||||
<span className="shrink-0 text-gray-300">
|
||||
{formatDate(
|
||||
doc.created_at,
|
||||
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 ${
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
49
frontend/src/app/components/shared/FileTypeIcon.tsx
Normal file
49
frontend/src/app/components/shared/FileTypeIcon.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { File, FileChartPie, FileSpreadsheet, FileText } from "lucide-react";
|
||||
|
||||
export type FileTypeKind = "pdf" | "word" | "excel" | "ppt" | "other";
|
||||
|
||||
/**
|
||||
* Normalize a file_type value (e.g. "pdf") or a filename (e.g. "deck.pptx")
|
||||
* into a coarse kind used to pick an icon. Accepts both because some call
|
||||
* sites only have the filename (user-message files, citations) while others
|
||||
* carry the document's `file_type` field.
|
||||
*/
|
||||
export function fileTypeKind(value: string | null | undefined): FileTypeKind {
|
||||
const raw = (value ?? "").toLowerCase().trim();
|
||||
const ext = raw.includes(".") ? (raw.split(".").pop() ?? "") : raw;
|
||||
if (ext === "pdf") return "pdf";
|
||||
if (ext === "docx" || ext === "doc") return "word";
|
||||
if (ext === "xlsx" || ext === "xlsm" || ext === "xls") return "excel";
|
||||
if (ext === "pptx" || ext === "ppt") return "ppt";
|
||||
return "other";
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical document file-type icon. Size and any extra classes come from
|
||||
* `className`; `shrink-0` is always applied. `muted` renders a neutral grey
|
||||
* placeholder (used for loading/disabled rows).
|
||||
*/
|
||||
export function FileTypeIcon({
|
||||
fileType,
|
||||
className = "h-3.5 w-3.5",
|
||||
muted = false,
|
||||
}: {
|
||||
fileType: string | null | undefined;
|
||||
className?: string;
|
||||
muted?: boolean;
|
||||
}) {
|
||||
const cls = `${className} shrink-0`;
|
||||
if (muted) return <File className={`${cls} text-gray-300`} />;
|
||||
switch (fileTypeKind(fileType)) {
|
||||
case "pdf":
|
||||
return <FileText className={`${cls} text-red-500`} />;
|
||||
case "word":
|
||||
return <File className={`${cls} text-blue-500`} />;
|
||||
case "excel":
|
||||
return <FileSpreadsheet className={`${cls} text-emerald-500`} />;
|
||||
case "ppt":
|
||||
return <FileChartPie className={`${cls} text-red-500`} />;
|
||||
default:
|
||||
return <File className={`${cls} text-gray-500`} />;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ 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 { needsMfaVerification } from "./MfaVerificationPopup";
|
||||
import { needsMfaVerification } from "../popups/MfaVerificationPopup";
|
||||
|
||||
type GateState = "idle" | "checking" | "required" | "verified";
|
||||
const MFA_VERIFIED_AT_KEY = "mike:mfa-verified-at";
|
||||
|
|
|
|||
|
|
@ -1,294 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ClipboardEvent,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { Modal } from "@/app/components/shared/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}
|
||||
title={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="space-y-5 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/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[];
|
||||
title?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
headerAction?: ReactNode;
|
||||
size?: ModalSize;
|
||||
className?: string;
|
||||
footerInfo?: ReactNode;
|
||||
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,
|
||||
title,
|
||||
icon,
|
||||
headerAction,
|
||||
size = "lg",
|
||||
className,
|
||||
footerInfo,
|
||||
footerStatus,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
cancelAction,
|
||||
}: ModalProps) {
|
||||
const hasHeader = breadcrumbs?.length || title || icon;
|
||||
const hasFooter =
|
||||
footerInfo ||
|
||||
footerStatus ||
|
||||
primaryAction ||
|
||||
secondaryAction ||
|
||||
cancelAction;
|
||||
const resolvedCancelAction =
|
||||
cancelAction === undefined && primaryAction
|
||||
? { label: "Cancel", onClick: onClose }
|
||||
: cancelAction;
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-[200] flex items-center justify-center px-4",
|
||||
"bg-white/30 backdrop-blur-[2px]",
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full rounded-3xl flex h-[600px] flex-col",
|
||||
sizeClassName[size],
|
||||
"border border-white/70 bg-white/94 shadow-[0_12px_36px_rgba(15,23,42,0.1)] backdrop-blur-2xl",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{hasHeader && (
|
||||
<div className="flex items-start justify-between gap-3 px-4 py-4">
|
||||
{breadcrumbs?.length ? (
|
||||
<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 text-gray-400">
|
||||
{breadcrumbs.map((segment, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{index > 0 && <span>›</span>}
|
||||
<span className="truncate">
|
||||
{segment}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{headerAction}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{icon}
|
||||
<h2 className="truncate text-base font-medium text-gray-900">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
{headerAction}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="shrink-0 text-gray-400 transition-colors hover:text-gray-600"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
|
||||
{children}
|
||||
</div>
|
||||
{hasFooter && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3",
|
||||
secondaryAction || footerInfo
|
||||
? "justify-between"
|
||||
: "justify-end",
|
||||
"border-t border-white/60",
|
||||
)}
|
||||
>
|
||||
{(secondaryAction || footerInfo) && (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{secondaryAction && (
|
||||
<ModalActionButton
|
||||
action={secondaryAction}
|
||||
fallbackVariant="secondary"
|
||||
/>
|
||||
)}
|
||||
{footerInfo}
|
||||
</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;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-1.5 px-4 py-1.5 text-sm font-medium transition-all disabled:cursor-not-allowed disabled:opacity-40",
|
||||
variant === "primary" &&
|
||||
"rounded-full border border-gray-700/40 bg-gray-950/88 text-white shadow-[0_3px_9px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(15,23,42,0.2)] backdrop-blur-xl hover:bg-gray-900/90 active:scale-[0.98] disabled:active:scale-100",
|
||||
variant === "secondary" && "text-gray-600 hover:text-gray-950",
|
||||
fallbackVariant === "secondary" &&
|
||||
variant === "secondary" &&
|
||||
"rounded-full border border-blue-500/35 bg-blue-600/90 text-white shadow-[0_3px_9px_rgba(37,99,235,0.16),inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-4px_9px_rgba(29,78,216,0.2)] backdrop-blur-xl hover:bg-blue-600 hover:text-white active:scale-[0.98] disabled:active:scale-100",
|
||||
variant === "danger" &&
|
||||
"px-1 text-red-600 hover:text-red-700 active:scale-[0.98] disabled:active:scale-100",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Lock } from "lucide-react";
|
||||
import { WarningPopup } from "./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" modal 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 OwnerOnlyModal({
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,362 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { User, UserPlus, Loader2, Plus } from "lucide-react";
|
||||
import type { ProjectPeople } from "@/app/lib/mikeApi";
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
type RosterRow = {
|
||||
email: string;
|
||||
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 [newEmail, setNewEmail] = useState("");
|
||||
const [busy, setBusy] = useState<"add" | "remove" | null>(null);
|
||||
const [removingEmail, setRemovingEmail] = 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 [peopleLoading, setPeopleLoading] = useState(false);
|
||||
|
||||
const resourceId = resource?.id ?? null;
|
||||
const sharedWith: string[] = Array.isArray(resource?.shared_with)
|
||||
? (resource!.shared_with as string[])
|
||||
: [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setNewEmail("");
|
||||
setError(null);
|
||||
setBusy(null);
|
||||
setRemovingEmail(null);
|
||||
}, [open]);
|
||||
|
||||
// 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(",");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !resourceId) return;
|
||||
let cancelled = false;
|
||||
setPeopleLoading(true);
|
||||
fetchPeople(resourceId)
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setPeople(data);
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep stale data; modal still works on emails alone */
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPeopleLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, resourceId, sharedKey, 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 ?? null;
|
||||
const ownerDisplayName = people?.owner.display_name ?? null;
|
||||
|
||||
const roster: RosterRow[] = [];
|
||||
if (ownerEmail) {
|
||||
roster.push({
|
||||
email: ownerEmail,
|
||||
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) ?? null,
|
||||
role: "member",
|
||||
});
|
||||
}
|
||||
|
||||
const trimmedNewEmail = newEmail.trim().toLowerCase();
|
||||
const normalizedCurrentUserEmail =
|
||||
currentUserEmail?.trim().toLowerCase() ?? null;
|
||||
const isValidEmail = EMAIL_RE.test(trimmedNewEmail);
|
||||
const sharedLower = sharedWith.map((e) => e.toLowerCase());
|
||||
const alreadyShared = sharedLower.includes(trimmedNewEmail);
|
||||
const isOwnerEmail =
|
||||
!!ownerEmail && trimmedNewEmail === ownerEmail.toLowerCase();
|
||||
const isSelfEmail =
|
||||
!!normalizedCurrentUserEmail &&
|
||||
trimmedNewEmail === normalizedCurrentUserEmail;
|
||||
const canAdd =
|
||||
isValidEmail &&
|
||||
!alreadyShared &&
|
||||
!isOwnerEmail &&
|
||||
!isSelfEmail &&
|
||||
busy === null;
|
||||
|
||||
async function handleAdd() {
|
||||
if (!canAdd || !onSharedWithChange) return;
|
||||
setBusy("add");
|
||||
setError(null);
|
||||
try {
|
||||
const next = [...sharedWith, trimmedNewEmail];
|
||||
await onSharedWithChange(next);
|
||||
setNewEmail("");
|
||||
} catch (e) {
|
||||
setError(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
breadcrumbs={breadcrumb}
|
||||
footerInfo={
|
||||
roster.length === 0
|
||||
? "No one has access yet."
|
||||
: `${roster.length} ${
|
||||
roster.length === 1 ? "person" : "people"
|
||||
} with access.`
|
||||
}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-6">
|
||||
{/* Add-member row */}
|
||||
{onSharedWithChange && (
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-1 items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
|
||||
<UserPlus className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Add by email…"
|
||||
value={newEmail}
|
||||
onChange={(e) =>
|
||||
setNewEmail(e.target.value)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleAdd();
|
||||
}}
|
||||
className="flex-1 bg-transparent text-sm text-gray-700 placeholder:text-gray-400 outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void handleAdd()}
|
||||
disabled={!canAdd}
|
||||
title="Add member"
|
||||
className="inline-flex items-center justify-center rounded-lg border border-gray-900 bg-gray-900 p-2 text-white hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy === "add" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{alreadyShared && trimmedNewEmail && (
|
||||
<p className="mt-1.5 text-xs text-gray-400">
|
||||
{trimmedNewEmail} already has access.
|
||||
</p>
|
||||
)}
|
||||
{isOwnerEmail && trimmedNewEmail && (
|
||||
<p className="mt-1.5 text-xs text-gray-400">
|
||||
{trimmedNewEmail} is the owner.
|
||||
</p>
|
||||
)}
|
||||
{isSelfEmail && !isOwnerEmail && trimmedNewEmail && (
|
||||
<p className="mt-1.5 text-xs text-gray-400">
|
||||
You cannot share this with yourself.
|
||||
</p>
|
||||
)}
|
||||
{trimmedNewEmail &&
|
||||
!isValidEmail &&
|
||||
!alreadyShared &&
|
||||
!isOwnerEmail &&
|
||||
!isSelfEmail && (
|
||||
<p className="mt-1.5 text-xs text-gray-400">
|
||||
Enter a valid email.
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="mt-1.5 text-xs text-red-500">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="min-h-0 flex-1">
|
||||
<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 */}
|
||||
{roster.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-gray-400">
|
||||
No one has access yet.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100 [&>li:nth-child(2)]:border-t-0">
|
||||
{roster.map((entry) => {
|
||||
const isYou =
|
||||
!!currentUserEmail &&
|
||||
entry.email.toLowerCase() ===
|
||||
currentUserEmail.toLowerCase();
|
||||
const isRemoving =
|
||||
busy === "remove" &&
|
||||
removingEmail === entry.email;
|
||||
const primary =
|
||||
entry.display_name?.trim() || entry.email;
|
||||
const showSecondary =
|
||||
!!entry.display_name?.trim() &&
|
||||
primary !== entry.email;
|
||||
return (
|
||||
<li
|
||||
key={`${entry.role}-${entry.email}`}
|
||||
className="flex items-center gap-3 py-3"
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-900 text-white">
|
||||
<User className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-gray-800">
|
||||
{primary}
|
||||
{isYou && (
|
||||
<span className="ml-1.5 text-xs text-gray-400">
|
||||
(You)
|
||||
</span>
|
||||
)}
|
||||
{entry.role === "owner" && (
|
||||
<span className="ml-1.5 text-[10px] text-gray-400">
|
||||
Owner
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{showSecondary && (
|
||||
<p className="truncate text-xs text-gray-400">
|
||||
{entry.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{entry.role === "member" &&
|
||||
onSharedWithChange && (
|
||||
<button
|
||||
onClick={() =>
|
||||
void handleRemove(
|
||||
entry.email,
|
||||
)
|
||||
}
|
||||
disabled={busy !== null}
|
||||
title="Remove access"
|
||||
className="self-center inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-gray-500 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
||||
>
|
||||
{isRemoving && (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
)}
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export function PreResponseWrapper({
|
||||
children,
|
||||
stepCount,
|
||||
shouldMinimize,
|
||||
isStreaming,
|
||||
compact = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
stepCount: number;
|
||||
shouldMinimize: boolean;
|
||||
isStreaming: boolean;
|
||||
/** Tighter typography + child gap for narrow side panels (e.g. TR chat). */
|
||||
compact?: 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 (shouldMinimize) hasMinimizedRef.current = true;
|
||||
if (userToggled) return;
|
||||
setIsOpen(!shouldMinimize && !hasMinimizedRef.current);
|
||||
}, [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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Folder, Search, X } from "lucide-react";
|
||||
import type { Project } from "./types";
|
||||
|
||||
interface Props {
|
||||
projects: Project[];
|
||||
loading: boolean;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export function ProjectPicker({ projects, loading, selectedId, onSelect }: Props) {
|
||||
const [search, setSearch] = useState("");
|
||||
const q = search.toLowerCase().trim();
|
||||
const filtered = q ? projects.filter((p) => p.name.toLowerCase().includes(q)) : projects;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pt-1 pb-2">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
|
||||
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search projects…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 bg-transparent text-sm text-gray-700 placeholder:text-gray-400 outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch("")} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto pb-2">
|
||||
{loading ? (
|
||||
<div className="rounded-sm border border-gray-100 overflow-hidden">
|
||||
<div className="flex items-center px-2 py-2">
|
||||
<div className="h-3 w-14 rounded bg-gray-200 animate-pulse" />
|
||||
</div>
|
||||
{[65, 45, 80, 55, 70].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-full border border-gray-200 shrink-0" />
|
||||
<div className="h-3.5 w-3.5 rounded bg-gray-200 animate-pulse shrink-0" />
|
||||
<div className="h-3 rounded bg-gray-200 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 border border-gray-100 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;
|
||||
return (
|
||||
<button
|
||||
key={project.id}
|
||||
onClick={() => onSelect(isSelected ? null : project.id)}
|
||||
className={`w-full flex items-center gap-2 px-2 py-2 text-xs transition-colors text-left ${isSelected ? "bg-gray-100" : "hover:bg-gray-50"}`}
|
||||
>
|
||||
<span className={`shrink-0 h-3.5 w-3.5 rounded-full 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-full 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 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="shrink-0 text-gray-400">{project.document_count ?? 0}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,297 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Minus, RectangleHorizontal, Rows3 } from "lucide-react";
|
||||
import { CiteButton } from "@/components/ui/cite-button";
|
||||
|
||||
export type RelevantQuoteItem = {
|
||||
id: string;
|
||||
quote: string;
|
||||
eyebrow?: string | null;
|
||||
inlineDetail?: string | null;
|
||||
detail?: string | null;
|
||||
citationText?: string | null;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
quotes: RelevantQuoteItem[];
|
||||
error?: string | null;
|
||||
isLoading?: boolean;
|
||||
activeQuoteId?: string | null;
|
||||
currentIndex?: number;
|
||||
citationRef?: number;
|
||||
citationText?: string;
|
||||
onSelect?: (quote: RelevantQuoteItem, index: number) => void;
|
||||
onIndexChange?: (index: number) => void;
|
||||
}
|
||||
|
||||
export function RelevantQuotes({
|
||||
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="rounded-lg border border-gray-200">
|
||||
<div className="flex h-10 items-center justify-between px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs font-medium text-gray-700">
|
||||
{typeof citationRef === "number"
|
||||
? `Citation ${citationRef}`
|
||||
: "Citation"}
|
||||
</p>
|
||||
{hasMultipleQuotes && (
|
||||
<div className="flex items-center gap-1">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{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 rounded-md border border-gray-200 bg-gray-50 px-3 py-2.5">
|
||||
<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="rounded-md border border-gray-200 bg-gray-50 px-3 py-2.5">
|
||||
<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: RelevantQuoteItem;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`w-full rounded-md border px-3 py-2.5 text-left transition-colors ${
|
||||
isActive
|
||||
? "border-blue-300 bg-blue-50"
|
||||
: "border-gray-200 bg-gray-50 hover:border-blue-300 hover:bg-blue-50/50"
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onCommit: (newValue: string) => void;
|
||||
suffix?: React.ReactNode;
|
||||
}
|
||||
|
||||
type CaretDocument = Document & {
|
||||
caretPositionFromPoint?: (
|
||||
x: number,
|
||||
y: number,
|
||||
) => { offset: number } | null;
|
||||
caretRangeFromPoint?: (x: number, y: number) => Range | null;
|
||||
};
|
||||
|
||||
export function RenameableTitle({ value, onCommit, suffix }: Props) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const caretPos = useRef<number | null>(null);
|
||||
const escaped = useRef(false);
|
||||
const committed = useRef(false);
|
||||
|
||||
function startEditing(e: React.MouseEvent) {
|
||||
const doc = document as CaretDocument;
|
||||
const caret = doc.caretPositionFromPoint?.(e.clientX, e.clientY);
|
||||
const range = !caret && doc.caretRangeFromPoint?.(e.clientX, e.clientY);
|
||||
caretPos.current = caret
|
||||
? caret.offset
|
||||
: range
|
||||
? range.startOffset
|
||||
: null;
|
||||
escaped.current = false;
|
||||
committed.current = false;
|
||||
setDraft(value);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function commit() {
|
||||
if (committed.current) return;
|
||||
if (escaped.current) {
|
||||
escaped.current = false;
|
||||
return;
|
||||
}
|
||||
committed.current = true;
|
||||
setEditing(false);
|
||||
onCommit(draft.trim());
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<input
|
||||
ref={(el) => {
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
if (caretPos.current !== null) {
|
||||
el.setSelectionRange(caretPos.current, caretPos.current);
|
||||
}
|
||||
}}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
escaped.current = true;
|
||||
committed.current = true;
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
onBlur={commit}
|
||||
className="text-gray-900 bg-transparent outline-none min-w-0"
|
||||
style={{ width: `${draft.length + 1}ch` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-block cursor-text text-gray-900 transition-colors hover:text-gray-600"
|
||||
onClick={startEditing}
|
||||
>
|
||||
{value}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ interface Props {
|
|||
onNewSubfolder?: () => void;
|
||||
deleting?: boolean;
|
||||
deleteDisabled?: boolean;
|
||||
onEditDetails?: () => void;
|
||||
onRename?: () => void;
|
||||
onUpdateCmNumber?: () => void;
|
||||
newSubfolderLabel?: string;
|
||||
|
|
@ -53,6 +54,7 @@ export function RowActionMenuItems({
|
|||
onNewSubfolder,
|
||||
deleting,
|
||||
deleteDisabled = false,
|
||||
onEditDetails,
|
||||
onRename,
|
||||
onUpdateCmNumber,
|
||||
newSubfolderLabel = "New subfolder",
|
||||
|
|
@ -80,6 +82,15 @@ export function RowActionMenuItems({
|
|||
{renameLabel}
|
||||
</button>
|
||||
)}
|
||||
{onEditDetails && (
|
||||
<button
|
||||
onClick={() => { onClose(); onEditDetails(); }}
|
||||
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 ${GLASS_MENU_ITEM}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit details
|
||||
</button>
|
||||
)}
|
||||
{onUpdateCmNumber && (
|
||||
<button
|
||||
onClick={() => { onClose(); onUpdateCmNumber(); }}
|
||||
|
|
@ -131,7 +142,7 @@ export function RowActionMenuItems({
|
|||
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 ${GLASS_MENU_ITEM}`}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
Unhide
|
||||
Activate
|
||||
</button>
|
||||
)}
|
||||
{onHide && (
|
||||
|
|
@ -140,7 +151,7 @@ export function RowActionMenuItems({
|
|||
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 ${GLASS_MENU_ITEM}`}
|
||||
>
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
Hide
|
||||
Deactivate
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
} from "@/components/ui/dropdown-menu";
|
||||
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
|
||||
import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
|
||||
import type { Chat } from "@/app/components/shared/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -146,7 +146,7 @@ export function SidebarChatItem({ chat, isActive, onSelect, projectName }: Props
|
|||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
<OwnerOnlyModal
|
||||
<OwnerOnlyPopup
|
||||
open={!!ownerOnlyAction}
|
||||
action={ownerOnlyAction ?? undefined}
|
||||
onClose={() => setOwnerOnlyAction(null)}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type HTMLAttributes,
|
||||
type MouseEvent,
|
||||
|
|
@ -45,13 +46,32 @@ export function TableScrollArea({
|
|||
children,
|
||||
className,
|
||||
innerClassName,
|
||||
}: DivProps & { innerClassName?: string }) {
|
||||
header,
|
||||
}: DivProps & { innerClassName?: string; header?: ReactNode }) {
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
function syncHeader() {
|
||||
if (headerRef.current && bodyRef.current) {
|
||||
headerRef.current.scrollLeft = bodyRef.current.scrollLeft;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("w-full min-h-0 flex-1 overflow-auto", className)}>
|
||||
<div className={cn("w-full min-h-0 flex-1 flex flex-col overflow-hidden", className)}>
|
||||
{header !== undefined && (
|
||||
<div ref={headerRef} className="shrink-0 overflow-hidden">
|
||||
{header}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn("flex min-h-full min-w-max flex-col", innerClassName)}
|
||||
ref={bodyRef}
|
||||
className="min-h-0 flex-1 overflow-auto"
|
||||
onScroll={header !== undefined ? syncHeader : undefined}
|
||||
>
|
||||
{children}
|
||||
<div className={cn("flex min-h-full min-w-max flex-col", innerClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -61,7 +81,7 @@ export function TableHeaderRow({ children, className, ...props }: DivProps) {
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 items-center border-b border-gray-200 pr-3 text-xs font-medium text-gray-500 select-none md:pr-10",
|
||||
"sticky top-0 z-[70] flex h-8 items-center border-b border-gray-200 bg-[#fafbfc] pr-3 text-xs font-medium text-gray-500 select-none md:pr-10",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -176,7 +196,7 @@ export function TableStickyCell({
|
|||
widthClassName,
|
||||
bgClassName,
|
||||
header
|
||||
? "items-center self-stretch"
|
||||
? "z-[80] items-center self-stretch"
|
||||
: "py-2 transition-colors",
|
||||
!header && hover && "group-hover:bg-gray-100",
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -1,123 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Upload } from "lucide-react";
|
||||
import { listDocumentVersions } from "@/app/lib/mikeApi";
|
||||
import type { Document } from "./types";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
doc: Document | null;
|
||||
onSubmit: (file: File, filename: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function UploadNewVersionModal({ open, onClose, doc, onSubmit }: Props) {
|
||||
const [name, setName] = useState("");
|
||||
const [stagedFile, setStagedFile] = useState<File | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [currentVersion, setCurrentVersion] = useState<number | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !doc) return;
|
||||
setName(doc.filename);
|
||||
setStagedFile(null);
|
||||
setSubmitting(false);
|
||||
setCurrentVersion(null);
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const { current_version_id, versions } =
|
||||
await listDocumentVersions(doc.id);
|
||||
const current = versions.find(
|
||||
(v) => v.id === current_version_id,
|
||||
);
|
||||
const initial =
|
||||
(current?.filename && current.filename.trim()) ||
|
||||
doc.filename;
|
||||
if (!cancelled) {
|
||||
setName(initial);
|
||||
setCurrentVersion(current?.version_number ?? null);
|
||||
}
|
||||
} catch {
|
||||
/* keep fallback */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, doc]);
|
||||
|
||||
if (!open || !doc) return null;
|
||||
|
||||
const accept = ".pdf,.docx,.doc";
|
||||
|
||||
function handleFilePick(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0] ?? null;
|
||||
setStagedFile(file);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!stagedFile || submitting || !doc) return;
|
||||
const finalName = name.trim() || doc.filename;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(stagedFile, finalName);
|
||||
onClose();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
breadcrumbs={["Upload new version", doc.filename]}
|
||||
secondaryAction={{
|
||||
label: stagedFile ? "Change file" : "Upload",
|
||||
icon: <Upload className="h-3.5 w-3.5" />,
|
||||
onClick: () => fileInputRef.current?.click(),
|
||||
disabled: submitting,
|
||||
}}
|
||||
primaryAction={{
|
||||
label: submitting ? "Saving…" : "Save",
|
||||
onClick: handleSubmit,
|
||||
disabled: !stagedFile || submitting,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
onChange={handleFilePick}
|
||||
/>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
New version name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Version name"
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-gray-400"
|
||||
/>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Current Version:{" "}
|
||||
<span className="text-gray-700 font-medium">
|
||||
{currentVersion ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
{stagedFile && (
|
||||
<div className="mt-2 text-xs text-gray-500 truncate">
|
||||
New Version File:{" "}
|
||||
<span className="text-gray-700">{stagedFile.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { AlertCircle, X } from "lucide-react";
|
||||
import { cn } from "@/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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ export interface Project {
|
|||
owner_email?: string | null;
|
||||
name: string;
|
||||
cm_number: string | null;
|
||||
practice: string | null;
|
||||
shared_with: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
|
@ -36,7 +37,7 @@ export interface Document {
|
|||
filename: string;
|
||||
owner_email?: string | null;
|
||||
owner_display_name?: string | null;
|
||||
file_type: string | null; // pdf | docx | doc
|
||||
file_type: string | null; // pdf | docx | doc | xlsx | xlsm | xls | pptx | ppt
|
||||
storage_path: string | null;
|
||||
pdf_storage_path: string | null;
|
||||
size_bytes: number | null;
|
||||
|
|
@ -105,6 +106,46 @@ export type AssistantEvent =
|
|||
error?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "ask_inputs";
|
||||
items: (
|
||||
| {
|
||||
id: string;
|
||||
kind: "choice";
|
||||
question: string;
|
||||
options: {
|
||||
value: string;
|
||||
}[];
|
||||
allow_other: boolean;
|
||||
other_label: string;
|
||||
response_prefix?: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "documents";
|
||||
document_types: string[];
|
||||
response_prefix?: string;
|
||||
}
|
||||
)[];
|
||||
}
|
||||
| {
|
||||
type: "ask_inputs_response";
|
||||
responses: (
|
||||
| {
|
||||
id: string;
|
||||
kind: "choice";
|
||||
question: string;
|
||||
answer?: string;
|
||||
skipped?: boolean;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "documents";
|
||||
filenames: string[];
|
||||
skipped?: boolean;
|
||||
}
|
||||
)[];
|
||||
}
|
||||
| { type: "thinking"; isStreaming?: boolean }
|
||||
| {
|
||||
type: "doc_read";
|
||||
|
|
@ -255,12 +296,13 @@ export type CaseCitationQuote = {
|
|||
};
|
||||
|
||||
export interface Message {
|
||||
id?: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
files?: { filename: string; document_id?: string }[];
|
||||
workflow?: { id: string; title: string };
|
||||
model?: string;
|
||||
annotations?: CitationAnnotation[];
|
||||
citations?: Citation[];
|
||||
citationStatus?: "started" | "partial" | "final";
|
||||
events?: AssistantEvent[];
|
||||
/** Set when streaming failed; rendered as a red error block. */
|
||||
|
|
@ -275,9 +317,15 @@ export interface CitationQuote {
|
|||
export type DocumentCitationQuote = {
|
||||
page: number | string;
|
||||
quote: string;
|
||||
/**
|
||||
* Spreadsheet citations are located by cell, not page: `sheet` is the
|
||||
* worksheet name and `cell` is an A1 address or range (e.g. "B7", "B7:C9").
|
||||
*/
|
||||
sheet?: string;
|
||||
cell?: string;
|
||||
};
|
||||
|
||||
export type DocumentCitationAnnotation = {
|
||||
export type DocumentCitation = {
|
||||
type: "citation_data";
|
||||
kind?: "document";
|
||||
ref: number;
|
||||
|
|
@ -286,13 +334,15 @@ export type DocumentCitationAnnotation = {
|
|||
version_id?: string | null;
|
||||
version_number?: number | null;
|
||||
filename: string;
|
||||
/** Legacy single-quote fields. Prefer `quotes` for new annotations. */
|
||||
/** Legacy single-quote fields. Prefer `quotes` for new citations. */
|
||||
page: number | string;
|
||||
quote: string;
|
||||
sheet?: string;
|
||||
cell?: string;
|
||||
quotes?: DocumentCitationQuote[];
|
||||
};
|
||||
|
||||
export type CaseCitationAnnotation = {
|
||||
export type CaseCitation = {
|
||||
type: "citation_data";
|
||||
kind: "case";
|
||||
ref: number;
|
||||
|
|
@ -310,12 +360,49 @@ export type CaseCitationAnnotation = {
|
|||
* anchors. Case citations anchor to a CourtListener cluster and include a
|
||||
* quoted opinion passage.
|
||||
*/
|
||||
export type CitationAnnotation =
|
||||
| DocumentCitationAnnotation
|
||||
| CaseCitationAnnotation;
|
||||
export type Citation =
|
||||
| DocumentCitation
|
||||
| CaseCitation;
|
||||
|
||||
const PAGE_BREAK_SENTINEL = "[[PAGE_BREAK]]";
|
||||
|
||||
export function isSpreadsheetFilename(filename: string): boolean {
|
||||
const ext = filename.split(".").pop()?.toLowerCase();
|
||||
return ext === "xlsx" || ext === "xlsm" || ext === "xls";
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable cell locator for a spreadsheet citation, e.g. "Sheet1!B7".
|
||||
* Falls back to whichever of `sheet`/`cell` is present.
|
||||
*/
|
||||
function formatCellLocator(sheet?: string, cell?: string): string {
|
||||
if (sheet && cell) return `${sheet}!${cell}`;
|
||||
return cell ?? sheet ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reader-friendly cell locator, e.g. "Sheet1, cell B7" (or "cells B7:C9" for a
|
||||
* range). Unlike `formatCellLocator`, this avoids the Excel `!` notation, which
|
||||
* reads poorly in prose. Used for the single-quote detail shown to the reader;
|
||||
* the machine-style `Sheet1!B7` form is kept where locators are joined together.
|
||||
*/
|
||||
function formatCellLocatorReadable(sheet?: string, cell?: string): string {
|
||||
if (!cell) return sheet ?? "";
|
||||
const cellWord = cell.includes(":") ? "cells" : "cell";
|
||||
const cellPart = `${cellWord} ${cell}`;
|
||||
return sheet ? `${sheet}, ${cellPart}` : cellPart;
|
||||
}
|
||||
|
||||
/** `{sheet, cell}` locators for a citation's quotes (spreadsheet sources). */
|
||||
export function getCitationCells(
|
||||
a: Citation,
|
||||
): { sheet?: string; cell?: string }[] {
|
||||
if (a.kind === "case") return [];
|
||||
return getDocumentCitationQuotes(a)
|
||||
.filter((q) => q.cell || q.sheet)
|
||||
.map((q) => ({ sheet: q.sheet, cell: q.cell }));
|
||||
}
|
||||
|
||||
function expandDocumentQuoteEntry(entry: DocumentCitationQuote): CitationQuote[] {
|
||||
const rangeMatch =
|
||||
typeof entry.page === "string"
|
||||
|
|
@ -339,13 +426,13 @@ function expandDocumentQuoteEntry(entry: DocumentCitationQuote): CitationQuote[]
|
|||
}
|
||||
|
||||
export function getDocumentCitationQuotes(
|
||||
a: CitationAnnotation,
|
||||
a: Citation,
|
||||
): DocumentCitationQuote[] {
|
||||
if (a.kind === "case") return [];
|
||||
if (Array.isArray(a.quotes) && a.quotes.length) {
|
||||
return a.quotes.filter((entry) => entry.quote.trim().length > 0);
|
||||
}
|
||||
return [{ page: a.page, quote: a.quote }];
|
||||
return [{ page: a.page, quote: a.quote, sheet: a.sheet, cell: a.cell }];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -354,36 +441,72 @@ export function getDocumentCitationQuotes(
|
|||
* cross-page citation with page "N-M" and a `[[PAGE_BREAK]]` split yields two.
|
||||
*/
|
||||
export function expandCitationToEntries(
|
||||
a: CitationAnnotation,
|
||||
a: Citation,
|
||||
): CitationQuote[] {
|
||||
if (a.kind === "case") return [];
|
||||
return getDocumentCitationQuotes(a).flatMap(expandDocumentQuoteEntry);
|
||||
}
|
||||
|
||||
/** Format the page(s) of a citation for display, e.g. "Page 3" or "Page 41-42". */
|
||||
export function formatCitationPage(a: CitationAnnotation): string {
|
||||
/**
|
||||
* Format the page(s) of a citation for display, e.g. "Page 3" or "Page 41-42".
|
||||
* Spreadsheets have no meaningful page locator, so this returns "" for them —
|
||||
* callers join with `.filter(Boolean)` so the locator is simply omitted.
|
||||
*/
|
||||
export function formatCitationPage(a: Citation): string {
|
||||
if (a.kind === "case") {
|
||||
return a.citation || a.case_name || `Case ${a.cluster_id}`;
|
||||
}
|
||||
const quotes = getDocumentCitationQuotes(a);
|
||||
// Spreadsheets are located by cell, e.g. "Sheet1!B7" (or several).
|
||||
if (isSpreadsheetFilename(a.filename)) {
|
||||
const cells = Array.from(
|
||||
new Set(
|
||||
quotes.map((q) => formatCellLocator(q.sheet, q.cell)).filter(Boolean),
|
||||
),
|
||||
);
|
||||
return cells.join(", ");
|
||||
}
|
||||
const pages = Array.from(
|
||||
new Set(quotes.map((q) => String(q.page)).filter(Boolean)),
|
||||
);
|
||||
if (pages.length > 1) return `Pages ${pages.join(", ")}`;
|
||||
if (pages.length === 1) return `Page ${pages[0]}`;
|
||||
if (typeof a.page === "string") return `Page ${a.page}`;
|
||||
return `Page ${a.page}`;
|
||||
}
|
||||
|
||||
/** Locator label for a single quote — "Page 3" for docs, "Sheet1, cell B7" for cells. */
|
||||
export function formatCitationQuotePage(
|
||||
a: Citation,
|
||||
page: number | string,
|
||||
quote?: DocumentCitationQuote,
|
||||
): string {
|
||||
if (a.kind !== "case" && isSpreadsheetFilename(a.filename)) {
|
||||
return formatCellLocatorReadable(quote?.sheet, quote?.cell);
|
||||
}
|
||||
return `Page ${page}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reader-friendly version of a single raw quote: replaces [[PAGE_BREAK]] with
|
||||
* "...". Spreadsheet quotes now carry plain cell values, so no stripping.
|
||||
*/
|
||||
export function cleanCitationQuoteText(
|
||||
_a: Citation,
|
||||
rawQuote: string,
|
||||
): string {
|
||||
return rawQuote.replaceAll(PAGE_BREAK_SENTINEL, "...");
|
||||
}
|
||||
|
||||
/** Produce a reader-friendly version of the quote (replaces [[PAGE_BREAK]] with "..."). */
|
||||
export function displayCitationQuote(a: CitationAnnotation): string {
|
||||
export function displayCitationQuote(a: Citation): string {
|
||||
if (a.kind === "case") {
|
||||
return a.quotes
|
||||
.map((q) => q.quote.replaceAll(PAGE_BREAK_SENTINEL, "..."))
|
||||
.join(" / ");
|
||||
}
|
||||
return getDocumentCitationQuotes(a)
|
||||
.map((q) => q.quote.replaceAll(PAGE_BREAK_SENTINEL, "..."))
|
||||
.map((q) => cleanCitationQuoteText(a, q.quote))
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
}
|
||||
|
||||
|
|
@ -442,6 +565,28 @@ export interface TabularCell {
|
|||
|
||||
// Workflows
|
||||
|
||||
export interface WorkflowOpenSourceSubmission {
|
||||
id: string;
|
||||
status: "pending" | "approved" | "rejected";
|
||||
submitted_at: string;
|
||||
updated_at: string;
|
||||
reviewed_at?: string | null;
|
||||
}
|
||||
|
||||
export interface OpenSourceWorkflowResponse
|
||||
extends WorkflowOpenSourceSubmission {
|
||||
mode: "created" | "updated";
|
||||
}
|
||||
|
||||
export type OpenSourceWorkflowContributorMode = "named" | "anonymous";
|
||||
|
||||
export interface WorkflowContributor {
|
||||
name: string;
|
||||
organisation: string | null;
|
||||
role: string | null;
|
||||
linkedin: string | null;
|
||||
}
|
||||
|
||||
export interface Workflow {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
|
|
@ -451,10 +596,14 @@ export interface Workflow {
|
|||
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;
|
||||
open_source_submission?: WorkflowOpenSourceSubmission | null;
|
||||
}
|
||||
|
||||
// API helpers
|
||||
|
|
|
|||
|
|
@ -42,13 +42,23 @@ export function useDirectoryData(enabled: boolean) {
|
|||
);
|
||||
return Promise.all(ps.map((p) => getProject(p.id))).then(
|
||||
(fullProjects) => {
|
||||
const projectCounts = new Map(
|
||||
ps.map((p) => [p.id, p.document_count ?? 0]),
|
||||
);
|
||||
const projectsWithCounts = fullProjects.map((project) => ({
|
||||
...project,
|
||||
document_count:
|
||||
project.documents?.length ??
|
||||
projectCounts.get(project.id) ??
|
||||
0,
|
||||
}));
|
||||
cache = {
|
||||
standaloneDocuments: sorted,
|
||||
projects: fullProjects,
|
||||
projects: projectsWithCounts,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
setStandaloneDocuments(sorted);
|
||||
setProjects(fullProjects);
|
||||
setProjects(projectsWithCounts);
|
||||
},
|
||||
);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { generateTabularColumnPrompt } from "@/app/lib/mikeApi";
|
|||
import { FORMAT_OPTIONS, formatLabel, formatIcon } from "./columnFormat";
|
||||
import { TAG_COLORS } from "./pillUtils";
|
||||
import { getPresetConfig, PROMPT_PRESETS } from "./columnPresets";
|
||||
import { ModalFieldLabel } from "../modals/ModalFieldLabel";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -328,9 +329,9 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
|
|||
|
||||
{/* Format */}
|
||||
<div className="mt-4">
|
||||
<label className="text-sm font-medium text-gray-500">
|
||||
<ModalFieldLabel className="text-gray-500">
|
||||
Format
|
||||
</label>
|
||||
</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">
|
||||
|
|
@ -379,9 +380,9 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
|
|||
{/* Tag input */}
|
||||
{column.format === "tag" && (
|
||||
<div className="mt-3">
|
||||
<label className="text-sm font-medium text-gray-500">
|
||||
<ModalFieldLabel className="text-gray-500">
|
||||
Tags
|
||||
</label>
|
||||
</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">
|
||||
{column.tags.map((tag, tagIdx) => (
|
||||
<span
|
||||
|
|
@ -434,9 +435,9 @@ export function AddColumnModal({ open, existingCount, onClose, onAdd, editingCol
|
|||
|
||||
{/* Prompt */}
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-500">
|
||||
<ModalFieldLabel className="mb-0 text-gray-500">
|
||||
Prompt
|
||||
</label>
|
||||
</ModalFieldLabel>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
|
|
|
|||
|
|
@ -1,488 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, ChevronDown, 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 { BUILT_IN_WORKFLOWS } from "../workflows/builtinWorkflows";
|
||||
import { Modal } from "../shared/Modal";
|
||||
|
||||
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 AddNewTRModal({
|
||||
open,
|
||||
onClose,
|
||||
onAdd,
|
||||
projects = [],
|
||||
projectDocs: fixedProjectDocs,
|
||||
projectName,
|
||||
projectCmNumber,
|
||||
}: Props) {
|
||||
const isProjectMode = fixedProjectDocs !== undefined;
|
||||
const [title, setTitle] = useState("");
|
||||
const [underProject, setUnderProject] = useState(false);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState("");
|
||||
const [projectDropdownOpen, setProjectDropdownOpen] = useState(false);
|
||||
|
||||
// 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 [workflowDropdownOpen, setWorkflowDropdownOpen] = useState(false);
|
||||
const formId = "new-tabular-review-modal-form";
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
setLoadingWorkflows(true);
|
||||
const builtinTabular = BUILT_IN_WORKFLOWS.filter(
|
||||
(w) => w.type === "tabular",
|
||||
);
|
||||
listWorkflows("tabular")
|
||||
.then((custom) => setWorkflows([...builtinTabular, ...custom]))
|
||||
.catch(() => setWorkflows(builtinTabular))
|
||||
.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() {
|
||||
setTitle("");
|
||||
setUnderProject(false);
|
||||
setSelectedProjectId("");
|
||||
setProjectDropdownOpen(false);
|
||||
setProjectDocs([]);
|
||||
setStandaloneDocs([]);
|
||||
setDirectoryProjects([]);
|
||||
setSelectedDocIds(new Set());
|
||||
setSelectedWorkflowId(null);
|
||||
setWorkflowDropdownOpen(false);
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
if (underProject && !selectedProjectId) 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);
|
||||
setProjectDropdownOpen(false);
|
||||
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 selectedProject = projects.find((p) => p.id === selectedProjectId);
|
||||
const selectedWorkflow = workflows.find((w) => w.id === selectedWorkflowId);
|
||||
|
||||
// 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}
|
||||
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,
|
||||
}}
|
||||
primaryAction={{
|
||||
label: "Create",
|
||||
type: "submit",
|
||||
form: formId,
|
||||
disabled: !title.trim() || (underProject && !selectedProjectId),
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.docx,.doc"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col min-h-0 flex-1"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Review name"
|
||||
className="w-full text-2xl font-serif text-gray-800 placeholder-gray-400 focus:outline-none bg-transparent"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Workflow template */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-gray-700">
|
||||
Workflow Template
|
||||
</p>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setWorkflowDropdownOpen((o) => !o)
|
||||
}
|
||||
disabled={loadingWorkflows}
|
||||
className="flex items-center justify-between w-full rounded-lg border border-gray-200 px-3 py-2 text-sm hover:border-gray-400 focus:outline-none bg-white transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{loadingWorkflows && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-gray-400 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
selectedWorkflow
|
||||
? "text-gray-800 truncate"
|
||||
: "text-gray-400"
|
||||
}
|
||||
>
|
||||
{loadingWorkflows
|
||||
? "Loading templates…"
|
||||
: selectedWorkflow
|
||||
? selectedWorkflow.title
|
||||
: "No template — start from scratch"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-gray-400 shrink-0 ml-2" />
|
||||
</button>
|
||||
{workflowDropdownOpen && !loadingWorkflows && (
|
||||
<div className="absolute left-0 top-full z-20 mt-1 w-full rounded-xl border border-gray-100 bg-white shadow-lg overflow-y-auto max-h-52">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedWorkflowId(null);
|
||||
setWorkflowDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-gray-50 ${!selectedWorkflowId ? "bg-gray-50 text-gray-900" : "text-gray-500"}`}
|
||||
>
|
||||
<span className="flex-1">
|
||||
No template — start from scratch
|
||||
</span>
|
||||
{!selectedWorkflowId && (
|
||||
<Check className="h-3.5 w-3.5 text-gray-500 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
{workflows.length > 0 && (
|
||||
<div className="border-t border-gray-100" />
|
||||
)}
|
||||
{workflows.map((wf) => (
|
||||
<button
|
||||
key={wf.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedWorkflowId(
|
||||
wf.id,
|
||||
);
|
||||
setWorkflowDropdownOpen(
|
||||
false,
|
||||
);
|
||||
}}
|
||||
className={`w-full text-left flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-gray-50 ${selectedWorkflowId === wf.id ? "bg-gray-50 text-gray-900" : "text-gray-700"}`}
|
||||
>
|
||||
<span className="flex-1 truncate">
|
||||
{wf.title}
|
||||
</span>
|
||||
{selectedWorkflowId ===
|
||||
wf.id && (
|
||||
<Check className="h-3.5 w-3.5 text-gray-500 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create under a project toggle */}
|
||||
{!isProjectMode && <div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = !underProject;
|
||||
setUnderProject(next);
|
||||
if (!next) {
|
||||
setSelectedProjectId("");
|
||||
setProjectDropdownOpen(false);
|
||||
setProjectDocs([]);
|
||||
setSelectedDocIds(new Set());
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2.5 w-fit"
|
||||
>
|
||||
<span
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors duration-200 ${underProject ? "bg-gray-900" : "bg-gray-200"}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-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 && (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setProjectDropdownOpen((o) => !o)
|
||||
}
|
||||
className="flex items-center justify-between w-full rounded-lg border border-gray-200 px-3 py-2 text-sm hover:border-gray-400 focus:outline-none bg-white transition-colors"
|
||||
>
|
||||
<span
|
||||
className={
|
||||
selectedProject
|
||||
? "text-gray-800"
|
||||
: "text-gray-400"
|
||||
}
|
||||
>
|
||||
{selectedProject
|
||||
? selectedProject.name +
|
||||
(selectedProject.cm_number
|
||||
? ` (#${selectedProject.cm_number})`
|
||||
: "")
|
||||
: "Select project…"}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||
</button>
|
||||
{projectDropdownOpen && (
|
||||
<div className="absolute left-0 top-full z-20 mt-1 w-full rounded-xl border border-gray-100 bg-white shadow-lg overflow-y-auto max-h-48">
|
||||
{projects.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-gray-400">
|
||||
No projects found
|
||||
</p>
|
||||
) : (
|
||||
projects.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleSelectProject(
|
||||
p.id,
|
||||
)
|
||||
}
|
||||
className={`w-full text-left flex items-center justify-between px-3 py-2 text-sm transition-colors hover:bg-gray-50 ${selectedProjectId === p.id ? "bg-gray-50 text-gray-900" : "text-gray-700"}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{p.name}
|
||||
{p.cm_number && (
|
||||
<span className="ml-1 text-gray-400">
|
||||
(#
|
||||
{
|
||||
p.cm_number
|
||||
}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{selectedProjectId ===
|
||||
p.id && (
|
||||
<Check className="h-3.5 w-3.5 text-gray-500 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
|
||||
{/* File directory */}
|
||||
{showDirectory && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-gray-700">
|
||||
Select Documents
|
||||
</p>
|
||||
<div>
|
||||
<FileDirectory
|
||||
standaloneDocs={
|
||||
isProjectMode
|
||||
? directoryStandalone
|
||||
: underProject
|
||||
? flatProjectDocs
|
||||
: directoryStandalone
|
||||
}
|
||||
directoryProjects={
|
||||
isProjectMode
|
||||
? []
|
||||
: underProject
|
||||
? []
|
||||
: directoryFolders
|
||||
}
|
||||
loading={directoryLoading}
|
||||
selectedIds={selectedDocIds}
|
||||
onChange={setSelectedDocIds}
|
||||
heading={isProjectMode ? "Project Documents" : "Documents"}
|
||||
emptyMessage={
|
||||
isProjectMode || underProject
|
||||
? "No ready documents in this project"
|
||||
: "No documents yet"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -25,8 +25,8 @@ import {
|
|||
} from "@/app/lib/mikeApi";
|
||||
import type { AssistantEvent, ColumnConfig, Document } from "../shared/types";
|
||||
import { ModelToggle } from "../assistant/ModelToggle";
|
||||
import { ApiKeyMissingModal } from "../shared/ApiKeyMissingModal";
|
||||
import { PreResponseWrapper } from "../shared/PreResponseWrapper";
|
||||
import { ApiKeyMissingPopup } from "../popups/ApiKeyMissingPopup";
|
||||
import { PreResponseWrapper } from "../assistant/PreResponseWrapper";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import {
|
||||
getModelProvider,
|
||||
|
|
@ -1444,9 +1444,7 @@ export function TRChatPanel({
|
|||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
data.type === "courtlistener_find_in_case_start"
|
||||
) {
|
||||
if (data.type === "courtlistener_find_in_case_start") {
|
||||
const searches = parseCourtlistenerCaseSearches(
|
||||
data.searches,
|
||||
);
|
||||
|
|
@ -1472,8 +1470,7 @@ export function TRChatPanel({
|
|||
);
|
||||
updateMatchingEvent(
|
||||
(e) =>
|
||||
e.type ===
|
||||
"courtlistener_find_in_case" &&
|
||||
e.type === "courtlistener_find_in_case" &&
|
||||
(searches?.length
|
||||
? Array.isArray(e.searches)
|
||||
: e.cluster_id ===
|
||||
|
|
@ -1481,8 +1478,7 @@ export function TRChatPanel({
|
|||
"number"
|
||||
? (data.cluster_id as number)
|
||||
: null) &&
|
||||
e.query ===
|
||||
(data.query as string)) &&
|
||||
e.query === (data.query as string)) &&
|
||||
!!e.isStreaming,
|
||||
() => ({
|
||||
type: "courtlistener_find_in_case",
|
||||
|
|
@ -1917,7 +1913,7 @@ export function TRChatPanel({
|
|||
onHeightChange={setInputHeight}
|
||||
/>
|
||||
|
||||
<ApiKeyMissingModal
|
||||
<ApiKeyMissingPopup
|
||||
open={apiKeyModalProvider !== null}
|
||||
provider={apiKeyModalProvider}
|
||||
onClose={() => setApiKeyModalProvider(null)}
|
||||
|
|
|
|||
|
|
@ -17,10 +17,12 @@ import type {
|
|||
Document,
|
||||
TabularCell,
|
||||
} from "../shared/types";
|
||||
import { isSpreadsheetFilename } from "../shared/types";
|
||||
import { preprocessCitations, type ParsedCitation } from "./citation-utils";
|
||||
import { getPillClass } from "./pillUtils";
|
||||
import { DocView } from "../shared/DocView";
|
||||
import { DocxView } from "../shared/DocxView";
|
||||
import { PdfView } from "../shared/views/PdfView";
|
||||
import { SpreadsheetView } from "../shared/views/SpreadsheetView";
|
||||
import { DocxView } from "../shared/views/DocxView";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function isDocxDocument(d: {
|
||||
|
|
@ -176,8 +178,10 @@ export function TRSidePanel({
|
|||
},
|
||||
]}
|
||||
/>
|
||||
) : isSpreadsheetFilename(doc.filename ?? "") ? (
|
||||
<SpreadsheetView documentId={doc.id} />
|
||||
) : (
|
||||
<DocView
|
||||
<PdfView
|
||||
doc={{ document_id: doc.id }}
|
||||
quote={docCitation.quote}
|
||||
fallbackPage={docCitation.page}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,13 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
) {
|
||||
const stickyCellBg = "bg-[#fafbfc]";
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
function syncHeader() {
|
||||
if (headerRef.current && scrollContainerRef.current) {
|
||||
headerRef.current.scrollLeft = scrollContainerRef.current.scrollLeft;
|
||||
}
|
||||
}
|
||||
const sortedColumns = [...columns].sort((a, b) => a.index - b.index);
|
||||
const totalContentWidth =
|
||||
DOC_COL_W_PX + sortedColumns.length * DATA_COL_W_PX + 32;
|
||||
|
|
@ -139,48 +146,52 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div
|
||||
className={`flex h-8 ${stickyCellBg}`}
|
||||
style={{ minWidth: skeletonContentWidth }}
|
||||
>
|
||||
<div className="shrink-0 overflow-hidden">
|
||||
<div
|
||||
className={`${DOC_COL_W} flex items-center gap-4 border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs font-medium text-gray-500`}
|
||||
>
|
||||
<SkeletonDot />
|
||||
<span>Document</span>
|
||||
</div>
|
||||
{Array.from({ length: SKELETON_COLS }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${COL_W} flex items-center border-b border-r border-gray-200 p-2`}
|
||||
>
|
||||
<SkeletonLine className="h-4 w-28" />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex-1 border-b border-gray-200 min-w-8" />
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{Array.from({ length: SKELETON_ROWS }).map((_, row) => (
|
||||
<div
|
||||
key={row}
|
||||
className={`flex h-10 ${row % 2 === 0 ? stickyCellBg : "bg-gray-50"}`}
|
||||
className={`flex h-8 ${stickyCellBg}`}
|
||||
style={{ minWidth: skeletonContentWidth }}
|
||||
>
|
||||
<div className={`${DOC_COL_W} flex items-center gap-4 border-b border-r border-gray-200 py-2 pl-4 pr-2`}>
|
||||
<div
|
||||
className={`${DOC_COL_W} flex items-center gap-4 border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs font-medium text-gray-500`}
|
||||
>
|
||||
<SkeletonDot />
|
||||
<SkeletonLine className="h-4 w-32" />
|
||||
<span>Document</span>
|
||||
</div>
|
||||
{Array.from({ length: SKELETON_COLS }).map((_, col) => (
|
||||
{Array.from({ length: SKELETON_COLS }).map((_, i) => (
|
||||
<div
|
||||
key={col}
|
||||
key={i}
|
||||
className={`${COL_W} flex items-center border-b border-r border-gray-200 p-2`}
|
||||
>
|
||||
<SkeletonLine className="h-4" />
|
||||
<SkeletonLine className="h-4 w-28" />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex-1 border-b border-gray-200 min-w-8" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Rows */}
|
||||
<div className="flex flex-1 flex-col overflow-auto min-h-0">
|
||||
{Array.from({ length: SKELETON_ROWS }).map((_, row) => (
|
||||
<div
|
||||
key={row}
|
||||
className={`flex h-10 ${row % 2 === 0 ? stickyCellBg : "bg-gray-50"}`}
|
||||
style={{ minWidth: skeletonContentWidth }}
|
||||
>
|
||||
<div className={`${DOC_COL_W} flex items-center gap-4 border-b border-r border-gray-200 py-2 pl-4 pr-2`}>
|
||||
<SkeletonDot />
|
||||
<SkeletonLine className="h-4 w-32" />
|
||||
</div>
|
||||
{Array.from({ length: SKELETON_COLS }).map((_, col) => (
|
||||
<div
|
||||
key={col}
|
||||
className={`${COL_W} flex items-center border-b border-r border-gray-200 p-2`}
|
||||
>
|
||||
<SkeletonLine className="h-4" />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex-1 border-b border-gray-200 min-w-8" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -192,7 +203,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex items-center border-b border-gray-200">
|
||||
<div className={`shrink-0 flex items-center border-b border-gray-200 ${stickyCellBg}`}>
|
||||
<div
|
||||
className={`${DOC_COL_W} border-r border-gray-200 py-2 pl-4 pr-2 text-xs font-medium text-gray-500 select-none`}
|
||||
>
|
||||
|
|
@ -234,57 +245,61 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-1 flex-col overflow-auto"
|
||||
ref={scrollContainerRef}
|
||||
>
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div
|
||||
className={`sticky top-0 z-20 flex h-8 ${stickyCellBg}`}
|
||||
style={{ minWidth: totalContentWidth }}
|
||||
>
|
||||
<div ref={headerRef} className="shrink-0 overflow-hidden">
|
||||
<div
|
||||
className={`sticky left-0 z-30 ${DOC_COL_W} ${stickyCellBg} border-b border-r border-gray-200 flex items-center gap-4 py-2 pl-4 pr-2 text-left text-xs font-medium text-gray-500 select-none`}
|
||||
className={`flex h-8 ${stickyCellBg}`}
|
||||
style={{ minWidth: totalContentWidth }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected;
|
||||
}}
|
||||
onChange={toggleAll}
|
||||
className={TABLE_CHECKBOX_CLASS}
|
||||
/>
|
||||
<span>Document</span>
|
||||
</div>
|
||||
{columns.map((col) => (
|
||||
<div
|
||||
key={col.index}
|
||||
className={`${COL_W} border-b border-r border-gray-200 p-2 text-left text-xs font-medium text-gray-500 select-none`}
|
||||
className={`sticky left-0 z-[80] ${DOC_COL_W} ${stickyCellBg} border-b border-r border-gray-200 flex items-center gap-4 py-2 pl-4 pr-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}
|
||||
disabled={savingColumn || savingColumnsConfig}
|
||||
onSave={onUpdateColumn}
|
||||
onDelete={onDeleteColumn}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected;
|
||||
}}
|
||||
onChange={toggleAll}
|
||||
className={TABLE_CHECKBOX_CLASS}
|
||||
/>
|
||||
<span>Document</span>
|
||||
</div>
|
||||
{columns.map((col) => (
|
||||
<div
|
||||
key={col.index}
|
||||
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}
|
||||
disabled={savingColumn || savingColumnsConfig}
|
||||
onSave={onUpdateColumn}
|
||||
onDelete={onDeleteColumn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex-1 border-b border-gray-200 flex items-center justify-start p-2 min-w-8">
|
||||
<button
|
||||
onClick={onAddColumn}
|
||||
disabled={savingColumn || savingColumnsConfig}
|
||||
className="flex items-center justify-center text-gray-400 hover:text-gray-700 transition-colors disabled:text-gray-200"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex-1 border-b border-gray-200 flex items-center justify-start p-2 min-w-8">
|
||||
<button
|
||||
onClick={onAddColumn}
|
||||
disabled={savingColumn || savingColumnsConfig}
|
||||
className="flex items-center justify-center text-gray-400 hover:text-gray-700 transition-colors disabled:text-gray-200"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
<div
|
||||
className="flex flex-1 flex-col overflow-auto min-h-0"
|
||||
ref={scrollContainerRef}
|
||||
onScroll={syncHeader}
|
||||
>
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{dragOverFiles && (
|
||||
<div className="absolute inset-0 z-[90] border-2 border-blue-400 bg-blue-50/40 pointer-events-none" />
|
||||
|
|
@ -385,6 +400,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
getTabularReview,
|
||||
getProject,
|
||||
getTabularReviewPeople,
|
||||
listProjects,
|
||||
regenerateTabularCell,
|
||||
streamTabularGeneration,
|
||||
updateTabularReview,
|
||||
|
|
@ -38,12 +39,12 @@ import type {
|
|||
} from "../shared/types";
|
||||
import { AddColumnModal } from "./AddColumnModal";
|
||||
import { TRWorkflowModal } from "./TRWorkflowModal";
|
||||
import { AddDocumentsModal } from "../shared/AddDocumentsModal";
|
||||
import { AddProjectDocsModal } from "../shared/AddProjectDocsModal";
|
||||
import { PeopleModal } from "../shared/PeopleModal";
|
||||
import { OwnerOnlyModal } from "../shared/OwnerOnlyModal";
|
||||
import { ApiKeyMissingModal } from "../shared/ApiKeyMissingModal";
|
||||
import { ConfirmPopup } from "../shared/ConfirmPopup";
|
||||
import { AddDocumentsModal } from "../modals/AddDocumentsModal";
|
||||
import { AddProjectDocsModal } from "../modals/AddProjectDocsModal";
|
||||
import { PeopleModal } from "../modals/PeopleModal";
|
||||
import { OwnerOnlyPopup } from "../popups/OwnerOnlyPopup";
|
||||
import { ApiKeyMissingPopup } from "../popups/ApiKeyMissingPopup";
|
||||
import { ConfirmPopup } from "../popups/ConfirmPopup";
|
||||
import { HeaderActionsMenu } from "../shared/HeaderActionsMenu";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
|
|
@ -56,6 +57,7 @@ import { TRSidePanel } from "./TRSidePanel";
|
|||
import { TRTable } from "./TRTable";
|
||||
import type { TRTableHandle } from "./TRTable";
|
||||
import { TRChatPanel } from "./TRChatPanel";
|
||||
import { TabularReviewDetailsModal } from "./TabularReviewDetailsModal";
|
||||
import { exportTabularReviewToExcel } from "./exportToExcel";
|
||||
import { useSidebar } from "@/app/contexts/SidebarContext";
|
||||
import { PageHeader } from "../shared/PageHeader";
|
||||
|
|
@ -79,6 +81,8 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
const [savingColumnsConfig, setSavingColumnsConfig] = useState(false);
|
||||
const [addColOpen, setAddColOpen] = useState(false);
|
||||
const [addDocsOpen, setAddDocsOpen] = useState(false);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [availableProjects, setAvailableProjects] = useState<Project[]>([]);
|
||||
const [peopleModalOpen, setPeopleModalOpen] = useState(false);
|
||||
const [workflowModalOpen, setWorkflowModalOpen] = useState(false);
|
||||
const [applyingWorkflow, setApplyingWorkflow] = useState(false);
|
||||
|
|
@ -161,6 +165,12 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
.then(setProject)
|
||||
.catch(() => {}),
|
||||
);
|
||||
} else {
|
||||
fetches.push(
|
||||
listProjects()
|
||||
.then(setAvailableProjects)
|
||||
.catch(() => setAvailableProjects([])),
|
||||
);
|
||||
}
|
||||
Promise.all(fetches).finally(() => setLoading(false));
|
||||
}, [reviewId, projectId]);
|
||||
|
|
@ -546,28 +556,40 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
await clearResultsForDocuments(documents.map((document) => document.id));
|
||||
}
|
||||
|
||||
async function handleTitleCommit(newTitle: string) {
|
||||
if (!newTitle || newTitle === review?.title) return;
|
||||
function requestReviewDetails() {
|
||||
if (review?.is_owner === false) {
|
||||
setOwnerOnlyAction("rename this tabular review");
|
||||
setOwnerOnlyAction("edit tabular review details");
|
||||
return;
|
||||
}
|
||||
setReview((prev) => (prev ? { ...prev, title: newTitle } : prev));
|
||||
await updateTabularReview(reviewId, { title: newTitle });
|
||||
setDetailsOpen(true);
|
||||
}
|
||||
|
||||
function requestReviewRename() {
|
||||
if (review?.is_owner === false) {
|
||||
setOwnerOnlyAction("rename this tabular review");
|
||||
async function handleDetailsSave(values: {
|
||||
title: string;
|
||||
projectId?: string | null;
|
||||
}) {
|
||||
if (!review || review.is_owner === false) {
|
||||
setOwnerOnlyAction("edit tabular review details");
|
||||
return;
|
||||
}
|
||||
const nextTitle = window.prompt(
|
||||
"Rename tabular review",
|
||||
review?.title ?? "Untitled Review",
|
||||
const updated = await updateTabularReview(reviewId, {
|
||||
title: values.title,
|
||||
project_id: values.projectId ?? null,
|
||||
});
|
||||
setReview((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
...updated,
|
||||
}
|
||||
: updated,
|
||||
);
|
||||
const trimmed = nextTitle?.trim();
|
||||
if (!trimmed) return;
|
||||
void handleTitleCommit(trimmed);
|
||||
if (!projectId && updated.project_id) {
|
||||
setDetailsOpen(false);
|
||||
router.push(
|
||||
`/projects/${updated.project_id}/tabular-reviews/${reviewId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function requestReviewDelete() {
|
||||
|
|
@ -716,9 +738,9 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
<HeaderActionsMenu
|
||||
items={[
|
||||
{
|
||||
label: "Rename",
|
||||
label: "Edit details",
|
||||
icon: Pencil,
|
||||
onSelect: requestReviewRename,
|
||||
onSelect: requestReviewDetails,
|
||||
},
|
||||
{
|
||||
label: "Apply workflow",
|
||||
|
|
@ -1046,6 +1068,16 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
/>
|
||||
)}
|
||||
|
||||
<TabularReviewDetailsModal
|
||||
open={detailsOpen}
|
||||
review={review}
|
||||
projects={project ? [project] : availableProjects}
|
||||
canEdit={review?.is_owner !== false}
|
||||
lockProject={Boolean(projectId)}
|
||||
onClose={() => setDetailsOpen(false)}
|
||||
onSave={handleDetailsSave}
|
||||
/>
|
||||
|
||||
<PeopleModal
|
||||
open={peopleModalOpen}
|
||||
onClose={() => setPeopleModalOpen(false)}
|
||||
|
|
@ -1124,13 +1156,13 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
onConfirm={() => void confirmReviewDelete()}
|
||||
/>
|
||||
|
||||
<OwnerOnlyModal
|
||||
<OwnerOnlyPopup
|
||||
open={!!ownerOnlyAction}
|
||||
action={ownerOnlyAction ?? undefined}
|
||||
onClose={() => setOwnerOnlyAction(null)}
|
||||
/>
|
||||
|
||||
<ApiKeyMissingModal
|
||||
<ApiKeyMissingPopup
|
||||
open={apiKeyModalProvider !== null}
|
||||
provider={apiKeyModalProvider}
|
||||
onClose={() => setApiKeyModalProvider(null)}
|
||||
|
|
|
|||
|
|
@ -1,60 +0,0 @@
|
|||
const PROMPT_PRESETS: Array<{
|
||||
matches: RegExp;
|
||||
prompt: (title: string) => string;
|
||||
}> = [
|
||||
{
|
||||
matches: /\bpart(y|ies)\b/i,
|
||||
prompt: () =>
|
||||
'Identify all parties referenced in the document. List their full names and describe each party\'s role or capacity in the agreement. If a party is not clearly identified, state "Not addressed".',
|
||||
},
|
||||
{
|
||||
matches: /\bchange of control\b/i,
|
||||
prompt: () =>
|
||||
'Identify any change of control provisions in the document. Summarize the trigger, the consequences, any consent requirements, and any related termination, acceleration, or notice obligations. If not addressed, state "Not addressed".',
|
||||
},
|
||||
{
|
||||
matches: /\bterminat(e|ion|ing)\b/i,
|
||||
prompt: () =>
|
||||
'Extract the termination provisions in the document. Summarize who may terminate, the termination triggers, any notice requirements, cure periods, and the consequences of termination. If not addressed, state "Not addressed".',
|
||||
},
|
||||
{
|
||||
matches: /\bgoverning law\b|\bjurisdiction\b/i,
|
||||
prompt: () =>
|
||||
'Identify the governing law and jurisdiction provisions in the document. State the governing law, the forum for disputes, and any submission to jurisdiction or venue requirements. If not addressed, state "Not addressed".',
|
||||
},
|
||||
{
|
||||
matches: /\bconfidential(ity)?\b|\bnon-?disclosure\b/i,
|
||||
prompt: () =>
|
||||
'Extract the confidentiality provisions in the document. Summarize the scope of confidential information, permitted disclosures, use restrictions, duration, and any carve-outs or exceptions. If not addressed, state "Not addressed".',
|
||||
},
|
||||
{
|
||||
matches: /\bassign(ment|ability)?\b/i,
|
||||
prompt: () =>
|
||||
'Identify any assignment provisions in the document. Summarize whether assignment is permitted, restricted, or requires consent, and note any exceptions or deemed assignments. If not addressed, state "Not addressed".',
|
||||
},
|
||||
{
|
||||
matches: /\bpayment\b|\bfees?\b/i,
|
||||
prompt: () =>
|
||||
'Extract the payment and fee terms in the document. Summarize payment obligations, amounts, timing, currencies, fee types, and any consequences for late or missed payment. If not addressed, state "Not addressed".',
|
||||
},
|
||||
];
|
||||
|
||||
export function getPresetTabularPrompt(title: string): string | null {
|
||||
const trimmedTitle = title.trim();
|
||||
if (!trimmedTitle) return null;
|
||||
|
||||
const preset = PROMPT_PRESETS.find(({ matches }) => matches.test(trimmedTitle));
|
||||
return preset ? preset.prompt(trimmedTitle) : null;
|
||||
}
|
||||
|
||||
export function buildFallbackTabularPrompt(title: string): string {
|
||||
const trimmedTitle = title.trim();
|
||||
if (!trimmedTitle) return "";
|
||||
|
||||
return (
|
||||
`Review each document and extract the information relevant to "${trimmedTitle}". ` +
|
||||
`Provide a concise, document-specific summary for this column. ` +
|
||||
`Include the key facts, dates, thresholds, parties, and conditions where applicable. ` +
|
||||
`If the document does not contain relevant information, return "Not addressed".`
|
||||
);
|
||||
}
|
||||
|
|
@ -1,577 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Folder, Search, X } from "lucide-react";
|
||||
import type { Document, Workflow } from "../shared/types";
|
||||
import { createTabularReview } from "@/app/lib/mikeApi";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useDirectoryData } from "../shared/useDirectoryData";
|
||||
import { FileDirectory } from "../shared/FileDirectory";
|
||||
import type { Project } from "../shared/types";
|
||||
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
|
||||
import { Modal } from "../shared/Modal";
|
||||
import { WorkflowPickerContent } from "./WorkflowPickerContent";
|
||||
import { workflowDetailPath } from "./workflowRoutes";
|
||||
|
||||
interface Props {
|
||||
workflows: Workflow[];
|
||||
workflow: Workflow | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toggle switch
|
||||
// ---------------------------------------------------------------------------
|
||||
function Toggle({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ${on ? "bg-gray-900" : "bg-gray-200"}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${on ? "translate-x-4" : "translate-x-0"}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simple project picker (input + dropdown)
|
||||
// ---------------------------------------------------------------------------
|
||||
function SimpleProjectPicker({
|
||||
projects,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
projects: Project[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const selected = projects.find((p) => p.id === selectedId);
|
||||
const filtered = search
|
||||
? projects.filter((p) =>
|
||||
p.name.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
: projects;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={selectedId ? (selected?.name ?? "") : search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setOpen(true);
|
||||
onSelect(null);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
placeholder="Select a project…"
|
||||
className="w-full text-xs text-gray-700 placeholder:text-gray-400 bg-gray-50 border border-gray-200 rounded-md px-3 py-2 outline-none"
|
||||
/>
|
||||
{selectedId && (
|
||||
<button
|
||||
onMouseDown={() => {
|
||||
onSelect(null);
|
||||
setSearch("");
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
{open && !selectedId && (
|
||||
<div className="absolute z-10 top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-md shadow-sm overflow-y-auto max-h-40">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-3 py-3 text-xs text-gray-400 text-center">
|
||||
No projects found
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onMouseDown={() => {
|
||||
onSelect(p.id);
|
||||
setSearch("");
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left hover:bg-gray-50 text-gray-700"
|
||||
>
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
{p.name}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DisplayWorkflowModal
|
||||
// ---------------------------------------------------------------------------
|
||||
export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
||||
const [screen, setScreen] = useState<"select" | "configure">("select");
|
||||
const [selected, setSelected] = useState<Workflow | null>(workflow);
|
||||
const [listSearch, setListSearch] = useState("");
|
||||
|
||||
// Configure screen state
|
||||
const [inProject, setInProject] = useState(false);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedDocIds, setSelectedDocIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [docSearch, setDocSearch] = useState("");
|
||||
const [assistantPrompt, setAssistantPrompt] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { saveChat, setNewChatMessages } = useChatHistoryContext();
|
||||
const {
|
||||
loading: dirLoading,
|
||||
projects,
|
||||
standaloneDocuments,
|
||||
} = useDirectoryData(screen === "configure");
|
||||
|
||||
useEffect(() => {
|
||||
if (workflow) {
|
||||
setSelected(workflow);
|
||||
setScreen("select");
|
||||
setListSearch("");
|
||||
} else {
|
||||
setSelected(null);
|
||||
}
|
||||
}, [workflow?.id]);
|
||||
|
||||
// Reset configure state on back
|
||||
useEffect(() => {
|
||||
if (screen === "select") {
|
||||
setInProject(false);
|
||||
setSelectedProjectId(null);
|
||||
setSelectedDocIds(new Set());
|
||||
setDocSearch("");
|
||||
setAssistantPrompt("");
|
||||
}
|
||||
}, [screen]);
|
||||
|
||||
function handleClose() {
|
||||
setSelected(null);
|
||||
setScreen("select");
|
||||
onClose();
|
||||
}
|
||||
|
||||
if (!workflow) return null;
|
||||
const wf = selected ?? workflow;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
async function handleStartChat() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const projectId = inProject ? selectedProjectId! : undefined;
|
||||
const chatId = await saveChat(projectId);
|
||||
if (!chatId) return;
|
||||
const allDocs: Document[] = [
|
||||
...standaloneDocuments,
|
||||
...projects.flatMap((p) => p.documents || []),
|
||||
];
|
||||
const files = allDocs
|
||||
.filter((d) => selectedDocIds.has(d.id))
|
||||
.map((d) => ({
|
||||
filename: d.filename,
|
||||
document_id: d.id,
|
||||
}));
|
||||
const content = assistantPrompt.trim()
|
||||
? `implement workflow\n\n${assistantPrompt.trim()}`
|
||||
: "implement workflow";
|
||||
setNewChatMessages([
|
||||
{
|
||||
role: "user",
|
||||
content,
|
||||
files: files.length > 0 ? files : undefined,
|
||||
},
|
||||
]);
|
||||
handleClose();
|
||||
router.push(
|
||||
projectId
|
||||
? `/projects/${projectId}/assistant/chat/${chatId}`
|
||||
: `/assistant/chat/${chatId}`,
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateReview() {
|
||||
const allDocs: Document[] = [
|
||||
...standaloneDocuments,
|
||||
...projects.flatMap((p) => p.documents || []),
|
||||
];
|
||||
const docIds = allDocs
|
||||
.filter((d) => selectedDocIds.has(d.id))
|
||||
.map((d) => d.id);
|
||||
const projectId = inProject ? selectedProjectId! : undefined;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const review = await createTabularReview({
|
||||
title: wf.title,
|
||||
document_ids: docIds,
|
||||
columns_config: wf.columns_config || [],
|
||||
workflow_id: wf.is_system ? undefined : wf.id,
|
||||
project_id: projectId,
|
||||
});
|
||||
handleClose();
|
||||
router.push(
|
||||
projectId
|
||||
? `/projects/${projectId}/tabular-reviews/${review.id}`
|
||||
: `/tabular-reviews/${review.id}`,
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tabular doc browser helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const q = docSearch.toLowerCase().trim();
|
||||
const selectedProject = projects.find((p) => p.id === selectedProjectId);
|
||||
const projectDocs = selectedProject?.documents ?? [];
|
||||
|
||||
const filteredProjectDocs = q
|
||||
? projectDocs.filter((d) =>
|
||||
d.filename.toLowerCase().includes(q),
|
||||
)
|
||||
: projectDocs;
|
||||
|
||||
const filteredStandalone = q
|
||||
? standaloneDocuments.filter((d) =>
|
||||
d.filename.toLowerCase().includes(q),
|
||||
)
|
||||
: standaloneDocuments;
|
||||
|
||||
const filteredAllProjects = projects
|
||||
.map((p) => ({
|
||||
...p,
|
||||
documents: (p.documents || []).filter(
|
||||
(d) =>
|
||||
!q || d.filename.toLowerCase().includes(q),
|
||||
),
|
||||
}))
|
||||
.filter(
|
||||
(p) =>
|
||||
!q ||
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.documents.length > 0,
|
||||
);
|
||||
|
||||
const breadcrumbs =
|
||||
screen === "select"
|
||||
? ["Workflows", "Select workflow"]
|
||||
: [
|
||||
<button
|
||||
key="workflows"
|
||||
type="button"
|
||||
onClick={() => setScreen("select")}
|
||||
className="transition-colors hover:text-gray-700"
|
||||
>
|
||||
Workflows
|
||||
</button>,
|
||||
wf.title,
|
||||
wf.type === "assistant" ? "New Chat" : "New Review",
|
||||
];
|
||||
|
||||
const selectPageAction = () => {
|
||||
router.push(workflowDetailPath(wf));
|
||||
handleClose();
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
return (
|
||||
<Modal
|
||||
open={!!workflow}
|
||||
onClose={handleClose}
|
||||
size={screen === "select" ? "xl" : "lg"}
|
||||
breadcrumbs={breadcrumbs}
|
||||
secondaryAction={
|
||||
screen === "select"
|
||||
? {
|
||||
label: wf.is_system ? "View Page" : "Edit",
|
||||
onClick: selectPageAction,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
footerStatus={
|
||||
screen === "configure" &&
|
||||
(wf.type === "assistant"
|
||||
? !inProject && selectedDocIds.size > 0
|
||||
: selectedDocIds.size > 0) ? (
|
||||
<span className="text-xs text-gray-400">
|
||||
{selectedDocIds.size} selected
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
primaryAction={
|
||||
screen === "select"
|
||||
? {
|
||||
label: "Use",
|
||||
onClick: () => setScreen("configure"),
|
||||
}
|
||||
: wf.type === "assistant"
|
||||
? {
|
||||
label: saving ? "Starting…" : "Start Chat",
|
||||
onClick: handleStartChat,
|
||||
disabled:
|
||||
saving || (inProject && !selectedProjectId),
|
||||
}
|
||||
: {
|
||||
label: saving ? "Creating…" : "Create Review",
|
||||
onClick: handleCreateReview,
|
||||
disabled:
|
||||
saving ||
|
||||
selectedDocIds.size === 0 ||
|
||||
(inProject && !selectedProjectId),
|
||||
}
|
||||
}
|
||||
cancelAction={false}
|
||||
>
|
||||
|
||||
{/* ── SELECT SCREEN ── */}
|
||||
{screen === "select" && (
|
||||
<WorkflowPickerContent
|
||||
workflows={workflows}
|
||||
selected={wf}
|
||||
onSelect={(next) => {
|
||||
if (next) setSelected(next);
|
||||
}}
|
||||
search={listSearch}
|
||||
onSearchChange={setListSearch}
|
||||
workflowType="all"
|
||||
previewMode="auto"
|
||||
showTypeIcon
|
||||
allowClearPreview={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── ASSISTANT CONFIGURE SCREEN ── */}
|
||||
{screen === "configure" && wf.type === "assistant" && (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
{/* Add-on prompt */}
|
||||
<div className="pb-3 shrink-0">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">
|
||||
Message (optional)
|
||||
</p>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={assistantPrompt}
|
||||
onChange={(e) =>
|
||||
setAssistantPrompt(e.target.value)
|
||||
}
|
||||
placeholder="Add any additional instructions to the workflow prompt…"
|
||||
className="w-full text-sm text-gray-700 placeholder:text-gray-400 bg-gray-50 border border-gray-200 rounded-md px-3 py-2 resize-none outline-none leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toggle row */}
|
||||
<div className="py-3 flex flex-col gap-2 shrink-0">
|
||||
<span className="text-xs font-medium text-gray-700">
|
||||
Create in a project
|
||||
</span>
|
||||
<Toggle
|
||||
on={inProject}
|
||||
onToggle={() => {
|
||||
setInProject(!inProject);
|
||||
setSelectedProjectId(null);
|
||||
setSelectedDocIds(new Set());
|
||||
setDocSearch("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{inProject ? (
|
||||
<>
|
||||
<div className="pt-1 pb-1 shrink-0">
|
||||
<p className="text-xs font-medium text-gray-700">
|
||||
Select project
|
||||
</p>
|
||||
</div>
|
||||
<div className="pb-2 shrink-0">
|
||||
<SimpleProjectPicker
|
||||
projects={projects}
|
||||
selectedId={selectedProjectId}
|
||||
onSelect={setSelectedProjectId}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="pt-1 pb-1 shrink-0">
|
||||
<p className="text-xs font-medium text-gray-700">
|
||||
Select documents
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="pt-1.5 pb-1 shrink-0">
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5 py-1">
|
||||
<Search className="h-3 w-3 text-gray-400 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
value={docSearch}
|
||||
onChange={(e) =>
|
||||
setDocSearch(e.target.value)
|
||||
}
|
||||
className="flex-1 bg-transparent text-xs text-gray-700 placeholder:text-gray-400 outline-none"
|
||||
/>
|
||||
{docSearch && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setDocSearch("")
|
||||
}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File browser */}
|
||||
<div className="flex-1 overflow-y-auto pb-2">
|
||||
<FileDirectory
|
||||
standaloneDocs={filteredStandalone}
|
||||
directoryProjects={
|
||||
filteredAllProjects
|
||||
}
|
||||
loading={dirLoading}
|
||||
selectedIds={selectedDocIds}
|
||||
onChange={setSelectedDocIds}
|
||||
allowMultiple
|
||||
forceExpanded={!!q}
|
||||
emptyMessage={
|
||||
q
|
||||
? "No matches found"
|
||||
: "No documents yet"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── TABULAR CONFIGURE SCREEN ── */}
|
||||
{screen === "configure" && wf.type === "tabular" && (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
{/* Toggle stacked */}
|
||||
<div className="pb-3 flex flex-col gap-2 shrink-0">
|
||||
<span className="text-xs font-medium text-gray-700">
|
||||
Create in a project
|
||||
</span>
|
||||
<Toggle
|
||||
on={inProject}
|
||||
onToggle={() => {
|
||||
setInProject(!inProject);
|
||||
setSelectedProjectId(null);
|
||||
setDocSearch("");
|
||||
setSelectedDocIds(new Set());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project section */}
|
||||
{inProject && (
|
||||
<>
|
||||
<div className="pt-1 pb-1 shrink-0">
|
||||
<p className="text-xs font-medium text-gray-700">
|
||||
Select Project
|
||||
</p>
|
||||
</div>
|
||||
<div className="pb-2 shrink-0">
|
||||
<SimpleProjectPicker
|
||||
projects={projects}
|
||||
selectedId={selectedProjectId}
|
||||
onSelect={(id) => {
|
||||
setSelectedProjectId(id);
|
||||
if (!id)
|
||||
setSelectedDocIds(
|
||||
new Set(),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Documents section */}
|
||||
<div className="pt-3 pb-1 shrink-0">
|
||||
<p className="text-xs font-medium text-gray-700">
|
||||
Select Documents
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="pt-1.5 pb-1 shrink-0">
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5 py-1">
|
||||
<Search className="h-3 w-3 text-gray-400 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
value={docSearch}
|
||||
onChange={(e) =>
|
||||
setDocSearch(e.target.value)
|
||||
}
|
||||
className="flex-1 bg-transparent text-xs text-gray-700 placeholder:text-gray-400 outline-none"
|
||||
/>
|
||||
{docSearch && (
|
||||
<button
|
||||
onClick={() => setDocSearch("")}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File browser */}
|
||||
<div className="flex-1 overflow-y-auto pb-2">
|
||||
<FileDirectory
|
||||
standaloneDocs={
|
||||
inProject
|
||||
? filteredProjectDocs
|
||||
: filteredStandalone
|
||||
}
|
||||
directoryProjects={
|
||||
inProject ? [] : filteredAllProjects
|
||||
}
|
||||
loading={dirLoading}
|
||||
selectedIds={selectedDocIds}
|
||||
onChange={setSelectedDocIds}
|
||||
allowMultiple
|
||||
forceExpanded={!!q || inProject}
|
||||
emptyMessage={
|
||||
q
|
||||
? "No matches found"
|
||||
: inProject
|
||||
? "No documents in this project"
|
||||
: "No documents yet"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,163 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { MessageSquare, Table2 } from "lucide-react";
|
||||
import { createWorkflow, updateWorkflow } from "@/app/lib/mikeApi";
|
||||
import type { Workflow } from "../shared/types";
|
||||
import { PRACTICE_OPTIONS } from "./practices";
|
||||
import { Modal } from "../shared/Modal";
|
||||
import { Modal } from "../modals/Modal";
|
||||
import { ModalFieldLabel } from "../modals/ModalFieldLabel";
|
||||
import { ModalSegmentedToggle } from "../modals/ModalSegmentedToggle";
|
||||
import { ModalSelect } from "../modals/ModalSelect";
|
||||
import { ModalTextInput } from "../modals/ModalTextInput";
|
||||
|
||||
const DEFAULT_LANGUAGE = "English";
|
||||
const DEFAULT_PRACTICE = "General Transactions";
|
||||
const DEFAULT_JURISDICTION = "General";
|
||||
const LANGUAGE_OPTIONS = [
|
||||
"English",
|
||||
"Chinese",
|
||||
"Spanish",
|
||||
"French",
|
||||
"German",
|
||||
"Japanese",
|
||||
"Korean",
|
||||
"Portuguese",
|
||||
"Italian",
|
||||
"Dutch",
|
||||
"Arabic",
|
||||
"Hebrew",
|
||||
"Persian",
|
||||
"Urdu",
|
||||
"Hindi",
|
||||
"Bengali",
|
||||
"Tamil",
|
||||
"Telugu",
|
||||
"Indonesian",
|
||||
"Malay",
|
||||
"Filipino",
|
||||
"Vietnamese",
|
||||
"Thai",
|
||||
"Burmese",
|
||||
"Khmer",
|
||||
"Lao",
|
||||
"Russian",
|
||||
"Ukrainian",
|
||||
"Turkish",
|
||||
"Polish",
|
||||
"Czech",
|
||||
"Romanian",
|
||||
"Greek",
|
||||
"Danish",
|
||||
"Finnish",
|
||||
"Norwegian",
|
||||
"Swedish",
|
||||
"Afrikaans",
|
||||
"Swahili",
|
||||
"Other",
|
||||
] as const;
|
||||
const JURISDICTION_OPTIONS = [
|
||||
"General",
|
||||
"United States",
|
||||
"England and Wales",
|
||||
"European Union",
|
||||
"Singapore",
|
||||
"Hong Kong",
|
||||
"Australia",
|
||||
"Canada",
|
||||
"India",
|
||||
"Malaysia",
|
||||
"Indonesia",
|
||||
"Philippines",
|
||||
"Thailand",
|
||||
"Vietnam",
|
||||
"Japan",
|
||||
"South Korea",
|
||||
"China",
|
||||
"Taiwan",
|
||||
"Germany",
|
||||
"France",
|
||||
"Netherlands",
|
||||
"Ireland",
|
||||
"Scotland",
|
||||
"Luxembourg",
|
||||
"Switzerland",
|
||||
"Cayman Islands",
|
||||
"British Virgin Islands",
|
||||
"United Arab Emirates",
|
||||
"Saudi Arabia",
|
||||
"Brazil",
|
||||
"Mexico",
|
||||
"Other",
|
||||
] as const;
|
||||
const US_STATE_OPTIONS = [
|
||||
"Alabama",
|
||||
"Alaska",
|
||||
"Arizona",
|
||||
"Arkansas",
|
||||
"California",
|
||||
"Colorado",
|
||||
"Connecticut",
|
||||
"Delaware",
|
||||
"Florida",
|
||||
"Georgia",
|
||||
"Hawaii",
|
||||
"Idaho",
|
||||
"Illinois",
|
||||
"Indiana",
|
||||
"Iowa",
|
||||
"Kansas",
|
||||
"Kentucky",
|
||||
"Louisiana",
|
||||
"Maine",
|
||||
"Maryland",
|
||||
"Massachusetts",
|
||||
"Michigan",
|
||||
"Minnesota",
|
||||
"Mississippi",
|
||||
"Missouri",
|
||||
"Montana",
|
||||
"Nebraska",
|
||||
"Nevada",
|
||||
"New Hampshire",
|
||||
"New Jersey",
|
||||
"New Mexico",
|
||||
"New York",
|
||||
"North Carolina",
|
||||
"North Dakota",
|
||||
"Ohio",
|
||||
"Oklahoma",
|
||||
"Oregon",
|
||||
"Pennsylvania",
|
||||
"Rhode Island",
|
||||
"South Carolina",
|
||||
"South Dakota",
|
||||
"Tennessee",
|
||||
"Texas",
|
||||
"Utah",
|
||||
"Vermont",
|
||||
"Virginia",
|
||||
"Washington",
|
||||
"West Virginia",
|
||||
"Wisconsin",
|
||||
"Wyoming",
|
||||
"District of Columbia",
|
||||
] as const;
|
||||
const CANADA_PROVINCE_OPTIONS = [
|
||||
"Alberta",
|
||||
"British Columbia",
|
||||
"Manitoba",
|
||||
"New Brunswick",
|
||||
"Newfoundland and Labrador",
|
||||
"Northwest Territories",
|
||||
"Nova Scotia",
|
||||
"Nunavut",
|
||||
"Ontario",
|
||||
"Prince Edward Island",
|
||||
"Quebec",
|
||||
"Saskatchewan",
|
||||
"Yukon",
|
||||
] as const;
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
|
@ -15,42 +167,153 @@ interface Props {
|
|||
onUpdated?: (workflow: Workflow) => void;
|
||||
}
|
||||
|
||||
export function NewWorkflowModal({ open, onClose, onCreated, editWorkflow, onUpdated }: Props) {
|
||||
export function NewWorkflowModal({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
editWorkflow,
|
||||
onUpdated,
|
||||
}: Props) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [type, setType] = useState<"assistant" | "tabular">("assistant");
|
||||
const [practice, setPractice] = useState<string>("");
|
||||
const [language, setLanguage] = useState(DEFAULT_LANGUAGE);
|
||||
const [customLanguage, setCustomLanguage] = useState("");
|
||||
const [practice, setPractice] = useState<string>(DEFAULT_PRACTICE);
|
||||
const [customPractice, setCustomPractice] = useState("");
|
||||
const [jurisdiction, setJurisdiction] = useState(DEFAULT_JURISDICTION);
|
||||
const [jurisdictionRegion, setJurisdictionRegion] = useState("");
|
||||
const [customJurisdiction, setCustomJurisdiction] = useState("");
|
||||
const [openDropdown, setOpenDropdown] = useState<
|
||||
"language" | "practice" | "jurisdiction" | "jurisdictionRegion" | null
|
||||
>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const customLanguageInputRef = useRef<HTMLInputElement>(null);
|
||||
const customInputRef = useRef<HTMLInputElement>(null);
|
||||
const customJurisdictionInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isEditing = !!editWorkflow;
|
||||
const isOthers = practice === "Others";
|
||||
const effectivePractice = isOthers ? (customPractice.trim() || null) : (practice || null);
|
||||
const isOtherLanguage = language === "Other";
|
||||
const isOtherPractice = practice === "Other";
|
||||
const isOtherJurisdiction = jurisdiction === "Other";
|
||||
const effectiveLanguage = isOtherLanguage
|
||||
? customLanguage.trim()
|
||||
: language.trim();
|
||||
const effectivePractice = isOtherPractice ? (customPractice.trim() || null) : (practice || null);
|
||||
const effectiveJurisdiction = isOtherJurisdiction
|
||||
? customJurisdiction.trim()
|
||||
: jurisdictionRegion.trim() || jurisdiction;
|
||||
const languageOptions = (
|
||||
(LANGUAGE_OPTIONS as readonly string[]).includes(language)
|
||||
? LANGUAGE_OPTIONS
|
||||
: [language, ...LANGUAGE_OPTIONS]
|
||||
).filter(Boolean);
|
||||
const baseJurisdictionOptions =
|
||||
(JURISDICTION_OPTIONS as readonly string[]).includes(jurisdiction)
|
||||
? JURISDICTION_OPTIONS
|
||||
: [jurisdiction, ...JURISDICTION_OPTIONS];
|
||||
const jurisdictionOptions = baseJurisdictionOptions.filter(Boolean);
|
||||
const jurisdictionRegionOptions =
|
||||
jurisdiction === "United States"
|
||||
? US_STATE_OPTIONS
|
||||
: jurisdiction === "Canada"
|
||||
? CANADA_PROVINCE_OPTIONS
|
||||
: [];
|
||||
const effectiveJurisdictions = effectiveJurisdiction
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const formId = "workflow-modal-form";
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setTitle("");
|
||||
setType("assistant");
|
||||
setLanguage(DEFAULT_LANGUAGE);
|
||||
setCustomLanguage("");
|
||||
setPractice(DEFAULT_PRACTICE);
|
||||
setCustomPractice("");
|
||||
setJurisdiction(DEFAULT_JURISDICTION);
|
||||
setJurisdictionRegion("");
|
||||
setCustomJurisdiction("");
|
||||
setOpenDropdown(null);
|
||||
setError("");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && editWorkflow) {
|
||||
setTitle(editWorkflow.title);
|
||||
setType(editWorkflow.type);
|
||||
const saved = editWorkflow.practice ?? "";
|
||||
const savedLanguage = editWorkflow.language ?? DEFAULT_LANGUAGE;
|
||||
const isKnownLanguage = (LANGUAGE_OPTIONS as readonly string[]).includes(savedLanguage);
|
||||
if (!isKnownLanguage && savedLanguage) {
|
||||
setLanguage("Other");
|
||||
setCustomLanguage(savedLanguage);
|
||||
} else {
|
||||
setLanguage(savedLanguage);
|
||||
setCustomLanguage("");
|
||||
}
|
||||
const savedJurisdiction = editWorkflow.jurisdictions?.length
|
||||
? editWorkflow.jurisdictions.join(", ")
|
||||
: DEFAULT_JURISDICTION;
|
||||
const isKnownJurisdiction =
|
||||
(JURISDICTION_OPTIONS as readonly string[]).includes(savedJurisdiction);
|
||||
const isUsState = (US_STATE_OPTIONS as readonly string[]).includes(
|
||||
savedJurisdiction,
|
||||
);
|
||||
const isCanadaProvince = (
|
||||
CANADA_PROVINCE_OPTIONS as readonly string[]
|
||||
).includes(savedJurisdiction);
|
||||
if (!isKnownJurisdiction && savedJurisdiction) {
|
||||
if (isUsState) {
|
||||
setJurisdiction("United States");
|
||||
setJurisdictionRegion(savedJurisdiction);
|
||||
setCustomJurisdiction("");
|
||||
} else if (isCanadaProvince) {
|
||||
setJurisdiction("Canada");
|
||||
setJurisdictionRegion(savedJurisdiction);
|
||||
setCustomJurisdiction("");
|
||||
} else {
|
||||
setJurisdiction("Other");
|
||||
setJurisdictionRegion("");
|
||||
setCustomJurisdiction(savedJurisdiction);
|
||||
}
|
||||
} else {
|
||||
setJurisdiction(savedJurisdiction);
|
||||
setJurisdictionRegion("");
|
||||
setCustomJurisdiction("");
|
||||
}
|
||||
const saved = editWorkflow.practice ?? DEFAULT_PRACTICE;
|
||||
const isKnown = (PRACTICE_OPTIONS as readonly string[]).includes(saved);
|
||||
if (!isKnown && saved) {
|
||||
setPractice("Others");
|
||||
setPractice("Other");
|
||||
setCustomPractice(saved);
|
||||
} else {
|
||||
setPractice(saved);
|
||||
setCustomPractice("");
|
||||
}
|
||||
setError("");
|
||||
} else if (open) {
|
||||
resetForm();
|
||||
}
|
||||
}, [open, editWorkflow?.id]);
|
||||
}, [open, editWorkflow, resetForm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOthers) {
|
||||
if (isOtherLanguage) {
|
||||
customLanguageInputRef.current?.focus();
|
||||
}
|
||||
}, [isOtherLanguage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOtherPractice) {
|
||||
customInputRef.current?.focus();
|
||||
}
|
||||
}, [isOthers]);
|
||||
}, [isOtherPractice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOtherJurisdiction) {
|
||||
customJurisdictionInputRef.current?.focus();
|
||||
}
|
||||
}, [isOtherJurisdiction]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
|
|
@ -63,14 +326,22 @@ export function NewWorkflowModal({ open, onClose, onCreated, editWorkflow, onUpd
|
|||
if (isEditing && editWorkflow) {
|
||||
const updated = await updateWorkflow(editWorkflow.id, {
|
||||
title: title.trim(),
|
||||
language: effectiveLanguage || null,
|
||||
practice: effectivePractice,
|
||||
jurisdictions: effectiveJurisdictions.length
|
||||
? effectiveJurisdictions
|
||||
: null,
|
||||
});
|
||||
onUpdated?.(updated);
|
||||
} else {
|
||||
const workflow = await createWorkflow({
|
||||
title: title.trim(),
|
||||
type,
|
||||
language: effectiveLanguage || null,
|
||||
practice: effectivePractice,
|
||||
jurisdictions: effectiveJurisdictions.length
|
||||
? effectiveJurisdictions
|
||||
: null,
|
||||
});
|
||||
onCreated(workflow);
|
||||
}
|
||||
|
|
@ -83,14 +354,6 @@ export function NewWorkflowModal({ open, onClose, onCreated, editWorkflow, onUpd
|
|||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setTitle("");
|
||||
setType("assistant");
|
||||
setPractice("");
|
||||
setCustomPractice("");
|
||||
setError("");
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
resetForm();
|
||||
onClose();
|
||||
|
|
@ -120,82 +383,198 @@ export function NewWorkflowModal({ open, onClose, onCreated, editWorkflow, onUpd
|
|||
<form
|
||||
id={formId}
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col flex-1 min-h-0"
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Workflow name"
|
||||
className="w-full text-2xl font-serif text-gray-800 placeholder-gray-300 focus:outline-none bg-transparent"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<ModalFieldLabel htmlFor="workflow-title">
|
||||
Title
|
||||
</ModalFieldLabel>
|
||||
<ModalTextInput
|
||||
id="workflow-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Add workflow name"
|
||||
variant="minimal"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<div className="mt-5">
|
||||
<p className="mb-2 text-sm font-medium text-gray-500">Type</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setType("assistant")}
|
||||
className={`flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||
type === "assistant"
|
||||
? "border-gray-900 bg-gray-900 text-white"
|
||||
: "border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
Assistant
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setType("tabular")}
|
||||
className={`flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||
type === "tabular"
|
||||
? "border-gray-900 bg-gray-900 text-white"
|
||||
: "border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<Table2 className="h-3 w-3" />
|
||||
Tabular
|
||||
</button>
|
||||
{!isEditing && (
|
||||
<div>
|
||||
<ModalFieldLabel as="p">Type</ModalFieldLabel>
|
||||
<ModalSegmentedToggle
|
||||
value={type}
|
||||
onChange={setType}
|
||||
options={[
|
||||
{
|
||||
value: "assistant",
|
||||
label: "Assistant",
|
||||
icon: MessageSquare,
|
||||
},
|
||||
{
|
||||
value: "tabular",
|
||||
label: "Tabular",
|
||||
icon: Table2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<div>
|
||||
<ModalFieldLabel htmlFor="workflow-language">
|
||||
Language
|
||||
</ModalFieldLabel>
|
||||
<ModalSelect
|
||||
id="workflow-language"
|
||||
value={language}
|
||||
options={languageOptions}
|
||||
open={openDropdown === "language"}
|
||||
onOpenChange={(nextOpen) =>
|
||||
setOpenDropdown((current) =>
|
||||
nextOpen
|
||||
? "language"
|
||||
: current === "language"
|
||||
? null
|
||||
: current,
|
||||
)
|
||||
}
|
||||
onChange={(value) => {
|
||||
setLanguage(value);
|
||||
if (value !== "Other") {
|
||||
setCustomLanguage("");
|
||||
}
|
||||
setOpenDropdown(null);
|
||||
}}
|
||||
/>
|
||||
{isOtherLanguage && (
|
||||
<ModalTextInput
|
||||
ref={customLanguageInputRef}
|
||||
type="text"
|
||||
value={customLanguage}
|
||||
onChange={(e) =>
|
||||
setCustomLanguage(e.target.value)
|
||||
}
|
||||
placeholder="Enter language…"
|
||||
className="mt-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ModalFieldLabel htmlFor="workflow-practice">
|
||||
Practice area
|
||||
</ModalFieldLabel>
|
||||
<ModalSelect
|
||||
id="workflow-practice"
|
||||
value={practice}
|
||||
options={PRACTICE_OPTIONS}
|
||||
open={openDropdown === "practice"}
|
||||
onOpenChange={(nextOpen) =>
|
||||
setOpenDropdown((current) =>
|
||||
nextOpen
|
||||
? "practice"
|
||||
: current === "practice"
|
||||
? null
|
||||
: current,
|
||||
)
|
||||
}
|
||||
onChange={(value) => {
|
||||
setPractice(value);
|
||||
if (value !== "Other") {
|
||||
setCustomPractice("");
|
||||
}
|
||||
setOpenDropdown(null);
|
||||
}}
|
||||
/>
|
||||
{isOtherPractice && (
|
||||
<ModalTextInput
|
||||
ref={customInputRef}
|
||||
type="text"
|
||||
value={customPractice}
|
||||
onChange={(e) =>
|
||||
setCustomPractice(e.target.value)
|
||||
}
|
||||
placeholder="Enter practice area…"
|
||||
className="mt-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5">
|
||||
<p className="mb-2 text-sm font-medium text-gray-500">Practice Area</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRACTICE_OPTIONS.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setPractice(practice === p ? "" : p)}
|
||||
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||
practice === p
|
||||
? "border-gray-900 bg-gray-900 text-white"
|
||||
: "border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{isOthers && (
|
||||
<input
|
||||
ref={customInputRef}
|
||||
type="text"
|
||||
value={customPractice}
|
||||
onChange={(e) => setCustomPractice(e.target.value)}
|
||||
placeholder="Enter practice area…"
|
||||
className="mt-3 w-full rounded-md border border-gray-200 px-3 py-1.5 text-sm text-gray-700 placeholder-gray-400 focus:border-gray-400 focus:outline-none"
|
||||
<div>
|
||||
<ModalFieldLabel htmlFor="workflow-jurisdiction">
|
||||
Jurisdiction
|
||||
</ModalFieldLabel>
|
||||
<ModalSelect
|
||||
id="workflow-jurisdiction"
|
||||
value={jurisdiction}
|
||||
options={jurisdictionOptions}
|
||||
open={openDropdown === "jurisdiction"}
|
||||
onOpenChange={(nextOpen) =>
|
||||
setOpenDropdown((current) =>
|
||||
nextOpen
|
||||
? "jurisdiction"
|
||||
: current === "jurisdiction"
|
||||
? null
|
||||
: current,
|
||||
)
|
||||
}
|
||||
onChange={(value) => {
|
||||
setJurisdiction(value);
|
||||
setJurisdictionRegion("");
|
||||
if (value !== "Other") {
|
||||
setCustomJurisdiction("");
|
||||
}
|
||||
setOpenDropdown(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{jurisdictionRegionOptions.length > 0 && (
|
||||
<ModalSelect
|
||||
id="workflow-jurisdiction-region"
|
||||
className="mt-2"
|
||||
value={jurisdictionRegion}
|
||||
options={jurisdictionRegionOptions}
|
||||
placeholder={
|
||||
jurisdiction === "United States"
|
||||
? "Select state..."
|
||||
: "Select province..."
|
||||
}
|
||||
open={openDropdown === "jurisdictionRegion"}
|
||||
onOpenChange={(nextOpen) =>
|
||||
setOpenDropdown((current) =>
|
||||
nextOpen
|
||||
? "jurisdictionRegion"
|
||||
: current === "jurisdictionRegion"
|
||||
? null
|
||||
: current,
|
||||
)
|
||||
}
|
||||
onChange={(value) => {
|
||||
setJurisdictionRegion(value);
|
||||
setOpenDropdown(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isOtherJurisdiction && (
|
||||
<ModalTextInput
|
||||
ref={customJurisdictionInputRef}
|
||||
type="text"
|
||||
value={customJurisdiction}
|
||||
onChange={(e) =>
|
||||
setCustomJurisdiction(e.target.value)
|
||||
}
|
||||
placeholder="Enter jurisdiction…"
|
||||
className="mt-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-4 text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,156 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import {
|
||||
deleteWorkflowShare,
|
||||
listWorkflowShares,
|
||||
shareWorkflow,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { EmailPillInput } from "../shared/EmailPillInput";
|
||||
import { Modal } from "../shared/Modal";
|
||||
|
||||
interface Share {
|
||||
id: string;
|
||||
shared_with_email: string;
|
||||
allow_edit: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
workflowId: string;
|
||||
workflowName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ShareWorkflowModal({
|
||||
workflowId,
|
||||
workflowName,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const [pendingEmails, setPendingEmails] = useState<string[]>([]);
|
||||
const [allowEdit, setAllowEdit] = useState(false);
|
||||
const [existingShares, setExistingShares] = useState<Share[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { user } = useAuth();
|
||||
const ownEmail = user?.email?.trim().toLowerCase() ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
listWorkflowShares(workflowId)
|
||||
.then(setExistingShares)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [workflowId]);
|
||||
|
||||
async function handleRemoveShare(shareId: string) {
|
||||
await deleteWorkflowShare(workflowId, shareId).catch(() => {});
|
||||
setExistingShares((prev) => prev.filter((s) => s.id !== shareId));
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
const emails = ownEmail
|
||||
? pendingEmails.filter((email) => email !== ownEmail)
|
||||
: pendingEmails;
|
||||
if (emails.length === 0) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await shareWorkflow(workflowId, { emails, allow_edit: allowEdit });
|
||||
const updated = await listWorkflowShares(workflowId);
|
||||
setExistingShares(updated);
|
||||
setPendingEmails([]);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: "Unable to share this workflow. Please try again.",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
breadcrumbs={["Workflows", workflowName, "People"]}
|
||||
primaryAction={{
|
||||
label: saving ? "Sharing…" : "Share",
|
||||
onClick: handleConfirm,
|
||||
disabled: saving || pendingEmails.length === 0,
|
||||
}}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-6">
|
||||
<section className="space-y-3">
|
||||
<EmailPillInput
|
||||
emails={pendingEmails}
|
||||
onChange={setPendingEmails}
|
||||
validate={async (email) =>
|
||||
ownEmail && email === ownEmail
|
||||
? "You cannot share a workflow with yourself."
|
||||
: null
|
||||
}
|
||||
placeholder="Add people by email…"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg bg-red-50 px-3 py-2 text-xs font-medium text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* Permission toggle */}
|
||||
<section className="flex flex-col gap-3">
|
||||
<span className="text-xs font-medium text-gray-700">Allow editing by share recipients</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAllowEdit((v) => !v)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ${allowEdit ? "bg-gray-900" : "bg-gray-200"}`}
|
||||
>
|
||||
<span className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${allowEdit ? "translate-x-4" : "translate-x-0"}`} />
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* Existing access */}
|
||||
<section className="min-h-0 flex-1">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">People with access</p>
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<div className="h-3 w-40 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : existingShares.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">None</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{existingShares.map((share) => (
|
||||
<div key={share.id} className="flex items-center justify-between py-1">
|
||||
<span className="text-sm text-gray-700 truncate">{share.shared_with_email}</span>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-xs text-gray-400">{share.allow_edit ? "Can edit" : "Read-only"}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveShare(share.id)}
|
||||
className="text-gray-300 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { createElement } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { ColumnConfig } from "../shared/types";
|
||||
import { formatIcon, formatLabel } from "../tabular/columnFormat";
|
||||
import { Modal } from "../shared/Modal";
|
||||
import { Modal } from "../modals/Modal";
|
||||
import { ModalFieldLabel } from "../modals/ModalFieldLabel";
|
||||
|
||||
interface Props {
|
||||
col: ColumnConfig;
|
||||
|
|
@ -12,7 +14,9 @@ interface Props {
|
|||
}
|
||||
|
||||
export function WFColumnViewModal({ col, onClose }: Props) {
|
||||
const FormatIcon = formatIcon(col.format ?? "text");
|
||||
const formatIconElement = createElement(formatIcon(col.format ?? "text"), {
|
||||
className: "h-3.5 w-3.5 text-gray-400",
|
||||
});
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
|
|
@ -24,21 +28,21 @@ export function WFColumnViewModal({ col, onClose }: Props) {
|
|||
}}
|
||||
cancelAction={false}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 mb-2">Column Title</p>
|
||||
<ModalFieldLabel as="p" className="text-gray-500">Column Title</ModalFieldLabel>
|
||||
<p className="text-sm text-gray-800">{col.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 mb-2">Format</p>
|
||||
<ModalFieldLabel as="p" className="text-gray-500">Format</ModalFieldLabel>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-gray-700">
|
||||
<FormatIcon className="h-3.5 w-3.5 text-gray-400" />
|
||||
{formatIconElement}
|
||||
{formatLabel(col.format ?? "text")}
|
||||
</span>
|
||||
</div>
|
||||
{col.tags && col.tags.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 mb-2.5">Tags</p>
|
||||
<ModalFieldLabel as="p" className="text-gray-500">Tags</ModalFieldLabel>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{col.tags.map((tag) => (
|
||||
<span key={tag} className="inline-block rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">{tag}</span>
|
||||
|
|
@ -47,7 +51,7 @@ export function WFColumnViewModal({ col, onClose }: Props) {
|
|||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 mb-2">Prompt</p>
|
||||
<ModalFieldLabel as="p" className="text-gray-500">Prompt</ModalFieldLabel>
|
||||
<div className="text-base text-gray-700 leading-relaxed font-serif prose prose-base max-w-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{col.prompt || "_No prompt defined._"}</ReactMarkdown>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { generateTabularColumnPrompt } from "@/app/lib/mikeApi";
|
|||
import { FORMAT_OPTIONS, formatLabel, formatIcon } from "../tabular/columnFormat";
|
||||
import { TAG_COLORS } from "../tabular/pillUtils";
|
||||
import { getPresetConfig, PROMPT_PRESETS } from "../tabular/columnPresets";
|
||||
import { ModalFieldLabel } from "../modals/ModalFieldLabel";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -128,7 +129,7 @@ export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props)
|
|||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100/70 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
|
@ -164,7 +165,7 @@ export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props)
|
|||
type="button"
|
||||
onClick={() => setPresetsOpen((v) => !v)}
|
||||
title="Column presets"
|
||||
className="mt-1.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700"
|
||||
className="mt-1.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100/70 hover:text-gray-700"
|
||||
>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${presetsOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
|
@ -173,7 +174,7 @@ export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props)
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => { update({ name: "", prompt: "", format: "text", tags: [], tagInput: "" }); setPresetsOpen(false); }}
|
||||
className="w-full px-3 py-2 text-left text-sm text-gray-400 hover:bg-gray-50 transition-colors border-b border-gray-100"
|
||||
className="w-full px-3 py-2 text-left text-sm text-gray-400 transition-all hover:bg-gray-100/70 border-b border-gray-100"
|
||||
>
|
||||
No Preset
|
||||
</button>
|
||||
|
|
@ -185,7 +186,7 @@ export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props)
|
|||
update({ name: preset.name, prompt: preset.prompt, format: preset.format, tags: preset.tags ?? [], tagInput: "" });
|
||||
setPresetsOpen(false);
|
||||
}}
|
||||
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
className="w-full px-3 py-2 text-left text-sm text-gray-700 transition-all hover:bg-gray-100/70"
|
||||
>
|
||||
{preset.name}
|
||||
</button>
|
||||
|
|
@ -197,7 +198,7 @@ export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props)
|
|||
|
||||
{/* Format */}
|
||||
<div className="mt-4">
|
||||
<label className="text-sm font-medium text-gray-500">Format</label>
|
||||
<ModalFieldLabel className="text-gray-500">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">
|
||||
|
|
@ -227,7 +228,7 @@ export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props)
|
|||
{/* Tag input */}
|
||||
{draft.format === "tag" && (
|
||||
<div className="mt-3">
|
||||
<label className="text-sm font-medium text-gray-500">Tags</label>
|
||||
<ModalFieldLabel className="text-gray-500">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">
|
||||
{draft.tags.map((tag, tagIdx) => (
|
||||
<span
|
||||
|
|
@ -260,7 +261,7 @@ export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props)
|
|||
|
||||
{/* Prompt */}
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-500">Prompt</label>
|
||||
<ModalFieldLabel className="mb-0 text-gray-500">Prompt</ModalFieldLabel>
|
||||
<button
|
||||
type="button"
|
||||
onClick={autoGeneratePrompt}
|
||||
|
|
@ -297,7 +298,7 @@ export function WFEditColumnModal({ column, onClose, onSave, onDelete }: Props)
|
|||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-4 py-2 text-sm text-gray-500 hover:bg-gray-100 transition-colors"
|
||||
className="rounded-lg px-4 py-2 text-sm text-gray-500 hover:bg-gray-100/70 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -6,25 +6,38 @@ import dynamic from "next/dynamic";
|
|||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Info,
|
||||
Globe,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { deleteWorkflow, getWorkflow, updateWorkflow } from "@/app/lib/mikeApi";
|
||||
import { ShareWorkflowModal } from "@/app/components/workflows/ShareWorkflowModal";
|
||||
import {
|
||||
deleteWorkflowShare,
|
||||
deleteWorkflow,
|
||||
getWorkflow,
|
||||
listWorkflowShares,
|
||||
lookupUserByEmail,
|
||||
shareWorkflow,
|
||||
updateWorkflow,
|
||||
type ProjectPeople,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import { UseWorkflowModal } from "@/app/components/workflows/UseWorkflowModal";
|
||||
import { WFEditColumnModal } from "@/app/components/workflows/WFEditColumnModal";
|
||||
import { WFColumnViewModal } from "@/app/components/workflows/WFColumnViewModal";
|
||||
import { AddColumnModal } from "@/app/components/tabular/AddColumnModal";
|
||||
import type { ColumnConfig, Workflow } from "@/app/components/shared/types";
|
||||
import { BUILT_IN_WORKFLOWS } from "@/app/components/workflows/builtinWorkflows";
|
||||
import type {
|
||||
ColumnConfig,
|
||||
Workflow,
|
||||
} from "@/app/components/shared/types";
|
||||
import { formatIcon, formatLabel } from "@/app/components/tabular/columnFormat";
|
||||
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
|
||||
import { ConfirmPopup } from "@/app/components/popups/ConfirmPopup";
|
||||
import { HeaderActionsMenu } from "@/app/components/shared/HeaderActionsMenu";
|
||||
import { PeopleModal } from "@/app/components/modals/PeopleModal";
|
||||
import { OpenSourceWorkflowModal } from "@/app/components/workflows/OpenSourceWorkflowModal";
|
||||
import { PageHeader } from "@/app/components/shared/PageHeader";
|
||||
import { WorkflowDetailsModal } from "@/app/components/workflows/WorkflowDetailsModal";
|
||||
import { NewWorkflowModal } from "@/app/components/workflows/NewWorkflowModal";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
// dynamic import keeps Tiptap (browser-only) out of the SSR bundle
|
||||
|
|
@ -43,8 +56,11 @@ interface Props {
|
|||
|
||||
type SaveStatus = "idle" | "saving" | "saved";
|
||||
type DeleteStatus = "idle" | "loading" | "complete";
|
||||
type WorkflowShare = Awaited<ReturnType<typeof listWorkflowShares>>[number];
|
||||
|
||||
const NAME_COL_W = "w-[332px] shrink-0";
|
||||
const WORKFLOW_CONTRIBUTIONS_ENABLED =
|
||||
process.env.NEXT_PUBLIC_WORKFLOW_CONTRIBUTIONS_ENABLED === "true";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
|
|
@ -54,20 +70,19 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
const { user } = useAuth();
|
||||
const { profile } = useUserProfile();
|
||||
const stickyCellBg = "bg-[#fafbfc]";
|
||||
const builtinWorkflow =
|
||||
BUILT_IN_WORKFLOWS.find((w) => w.id === id && w.type === workflowType) ??
|
||||
null;
|
||||
const isBuiltin = builtinWorkflow !== null;
|
||||
|
||||
const [workflow, setWorkflow] = useState<Workflow | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
|
||||
const readOnly =
|
||||
isBuiltin ||
|
||||
(workflow?.is_system ?? false) ||
|
||||
workflow?.allow_edit === false;
|
||||
const canShare = !readOnly && (workflow?.is_owner ?? true);
|
||||
const canOpenSource =
|
||||
WORKFLOW_CONTRIBUTIONS_ENABLED &&
|
||||
canShare &&
|
||||
workflow?.is_system !== true;
|
||||
|
||||
// Editor state
|
||||
const [promptMd, setPromptMd] = useState("");
|
||||
|
|
@ -85,11 +100,14 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
const [editingColumn, setEditingColumn] = useState<ColumnConfig | null>(null);
|
||||
const [viewingColumn, setViewingColumn] = useState<ColumnConfig | null>(null);
|
||||
|
||||
// Share popover
|
||||
// Share / use / details popovers
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const [workflowSharedWith, setWorkflowSharedWith] = useState<string[]>([]);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [useOpen, setUseOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleteStatus, setDeleteStatus] = useState<DeleteStatus>("idle");
|
||||
const [openSourceOpen, setOpenSourceOpen] = useState(false);
|
||||
|
||||
// Column actions dropdown
|
||||
const [colActionsOpen, setColActionsOpen] = useState(false);
|
||||
|
|
@ -109,19 +127,6 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
// Load workflow
|
||||
// ---------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
if (isBuiltin) {
|
||||
const wf = builtinWorkflow;
|
||||
if (!wf) {
|
||||
setNotFound(true);
|
||||
} else {
|
||||
setWorkflow(wf);
|
||||
setPromptMd(wf.prompt_md ?? "");
|
||||
setColumns(wf.columns_config ?? []);
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
getWorkflow(id)
|
||||
.then((wf) => {
|
||||
if (wf.type !== workflowType) {
|
||||
|
|
@ -138,7 +143,83 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
})
|
||||
.catch(() => setNotFound(true))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, isBuiltin, builtinWorkflow, workflowType]);
|
||||
}, [id, workflowType]);
|
||||
|
||||
const fetchWorkflowShares = useCallback(async () => {
|
||||
const shares = await listWorkflowShares(id);
|
||||
setWorkflowSharedWith(
|
||||
shares.map((share) => share.shared_with_email.trim().toLowerCase()),
|
||||
);
|
||||
return shares;
|
||||
}, [id]);
|
||||
|
||||
const fetchWorkflowPeople = useCallback(async (): Promise<ProjectPeople> => {
|
||||
const shares = await fetchWorkflowShares();
|
||||
const members = await Promise.all(
|
||||
shares.map(async (share) => {
|
||||
const email = share.shared_with_email.trim().toLowerCase();
|
||||
const userResult = await lookupUserByEmail(email).catch(
|
||||
() => null,
|
||||
);
|
||||
return {
|
||||
email,
|
||||
display_name:
|
||||
userResult?.exists === true
|
||||
? userResult.display_name
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
owner: {
|
||||
user_id: user?.id ?? workflow?.user_id ?? "",
|
||||
email: user?.email ?? null,
|
||||
display_name: profile?.displayName ?? null,
|
||||
},
|
||||
members,
|
||||
};
|
||||
}, [
|
||||
fetchWorkflowShares,
|
||||
profile?.displayName,
|
||||
user?.email,
|
||||
user?.id,
|
||||
workflow?.user_id,
|
||||
]);
|
||||
|
||||
async function handleWorkflowSharedWithChange(nextSharedWith: string[]) {
|
||||
const nextEmails = [
|
||||
...new Set(
|
||||
nextSharedWith
|
||||
.map((email) => email.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
const currentShares = await listWorkflowShares(id);
|
||||
const currentByEmail = new Map<string, WorkflowShare>();
|
||||
for (const share of currentShares) {
|
||||
currentByEmail.set(
|
||||
share.shared_with_email.trim().toLowerCase(),
|
||||
share,
|
||||
);
|
||||
}
|
||||
|
||||
const added = nextEmails.filter((email) => !currentByEmail.has(email));
|
||||
const removed = currentShares.filter(
|
||||
(share) =>
|
||||
!nextEmails.includes(
|
||||
share.shared_with_email.trim().toLowerCase(),
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
...removed.map((share) => deleteWorkflowShare(id, share.id)),
|
||||
...(added.length > 0
|
||||
? [shareWorkflow(id, { emails: added, allow_edit: false })]
|
||||
: []),
|
||||
]);
|
||||
|
||||
await fetchWorkflowShares();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Debounced auto-save for prompt
|
||||
|
|
@ -161,17 +242,6 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
[id, readOnly],
|
||||
);
|
||||
|
||||
async function handleWorkflowDetailsSave(values: { title: string }) {
|
||||
if (!workflow || readOnly || !values.title) return;
|
||||
if (values.title === workflow.title) return;
|
||||
const updated = await updateWorkflow(id, { title: values.title });
|
||||
setWorkflow({
|
||||
...updated,
|
||||
shared_by_name:
|
||||
updated.shared_by_name ?? workflow.shared_by_name ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDeleteWorkflow() {
|
||||
if (!workflow || readOnly || workflow.is_owner === false) return;
|
||||
setDeleteStatus("loading");
|
||||
|
|
@ -198,7 +268,13 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
setSaveStatus("saving");
|
||||
try {
|
||||
const updated = await updateWorkflow(id, { columns_config: next });
|
||||
setWorkflow(updated);
|
||||
setWorkflow((current) => ({
|
||||
...updated,
|
||||
open_source_submission:
|
||||
updated.open_source_submission ??
|
||||
current?.open_source_submission ??
|
||||
null,
|
||||
}));
|
||||
setSaveStatus("saved");
|
||||
setTimeout(() => setSaveStatus("idle"), 2000);
|
||||
} catch {
|
||||
|
|
@ -243,6 +319,7 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
]}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<WorkflowMetadataSkeleton />
|
||||
{workflowType === "tabular" ? (
|
||||
<TabularWorkflowEditorSkeleton />
|
||||
) : (
|
||||
|
|
@ -261,6 +338,11 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
);
|
||||
}
|
||||
|
||||
const defaultContributorName =
|
||||
profile?.displayName?.trim() || user?.email || "your account name";
|
||||
const openSourcePending =
|
||||
workflow.open_source_submission?.status === "pending";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Page header */}
|
||||
|
|
@ -281,6 +363,10 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
},
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
label: "Use",
|
||||
onClick: () => setUseOpen(true),
|
||||
},
|
||||
saveStatus !== "idle"
|
||||
? {
|
||||
type: "custom",
|
||||
|
|
@ -312,17 +398,24 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
title="Workflow actions"
|
||||
items={[
|
||||
{
|
||||
label: "Rename",
|
||||
label: "Edit details",
|
||||
icon: Pencil,
|
||||
onSelect: () =>
|
||||
setDetailsOpen(true),
|
||||
},
|
||||
{
|
||||
label: "Workflow Details",
|
||||
icon: Info,
|
||||
onSelect: () =>
|
||||
setDetailsOpen(true),
|
||||
},
|
||||
...(canOpenSource
|
||||
? [
|
||||
{
|
||||
label:
|
||||
"Open source this",
|
||||
icon: Globe,
|
||||
onSelect: () =>
|
||||
setOpenSourceOpen(
|
||||
true,
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
|
|
@ -341,25 +434,50 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
: null,
|
||||
]}
|
||||
/>
|
||||
<WorkflowDetailsModal
|
||||
<UseWorkflowModal
|
||||
workflows={[]}
|
||||
workflow={useOpen ? workflow : null}
|
||||
onClose={() => setUseOpen(false)}
|
||||
skipSelect
|
||||
/>
|
||||
<NewWorkflowModal
|
||||
open={detailsOpen}
|
||||
workflow={workflow}
|
||||
canEdit={!readOnly}
|
||||
canShare={canShare}
|
||||
currentUserDisplayName={profile?.displayName}
|
||||
currentUserEmail={user?.email}
|
||||
editWorkflow={workflow}
|
||||
onClose={() => setDetailsOpen(false)}
|
||||
onSave={handleWorkflowDetailsSave}
|
||||
onShareWorkflow={() => {
|
||||
onCreated={() => undefined}
|
||||
onUpdated={(updated) => {
|
||||
setWorkflow((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
...updated,
|
||||
shared_by_name:
|
||||
updated.shared_by_name ??
|
||||
current.shared_by_name ??
|
||||
null,
|
||||
open_source_submission:
|
||||
updated.open_source_submission ??
|
||||
current.open_source_submission ??
|
||||
null,
|
||||
}
|
||||
: updated,
|
||||
);
|
||||
setDetailsOpen(false);
|
||||
setShareOpen(true);
|
||||
}}
|
||||
/>
|
||||
{shareOpen && (
|
||||
<ShareWorkflowModal
|
||||
workflowId={id}
|
||||
workflowName={workflow.title}
|
||||
<PeopleModal
|
||||
open={shareOpen}
|
||||
onClose={() => setShareOpen(false)}
|
||||
resource={{ id, shared_with: workflowSharedWith }}
|
||||
fetchPeople={fetchWorkflowPeople}
|
||||
currentUserEmail={user?.email ?? null}
|
||||
breadcrumb={[
|
||||
"Workflows",
|
||||
workflow.title,
|
||||
"People",
|
||||
]}
|
||||
onSharedWithChange={handleWorkflowSharedWithChange}
|
||||
/>
|
||||
)}
|
||||
<ConfirmPopup
|
||||
|
|
@ -375,9 +493,29 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
setDeleteStatus("idle");
|
||||
}}
|
||||
/>
|
||||
<OpenSourceWorkflowModal
|
||||
open={openSourceOpen}
|
||||
onClose={() => setOpenSourceOpen(false)}
|
||||
workflowId={id}
|
||||
defaultContributorName={defaultContributorName}
|
||||
pending={openSourcePending}
|
||||
onSubmitted={(submission) =>
|
||||
setWorkflow((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
open_source_submission: submission,
|
||||
}
|
||||
: current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{/* Metadata */}
|
||||
<WorkflowMetadata workflow={workflow} />
|
||||
|
||||
{workflow.type === "assistant" ? (
|
||||
/* ── Assistant: WYSIWYG editor ── */
|
||||
<div className="flex-1 min-h-0 px-4 pb-2 pt-0 md:px-10 md:pb-3">
|
||||
|
|
@ -488,9 +626,9 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
<div
|
||||
key={col.index}
|
||||
onClick={() => readOnly ? setViewingColumn(col) : setEditingColumn(col)}
|
||||
className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-100 cursor-pointer transition-colors"
|
||||
className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-100/70 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className={`sticky left-0 z-[60] ${NAME_COL_W} py-2 pl-4 pr-2 ${isChecked ? "bg-gray-50" : stickyCellBg} transition-colors group-hover:bg-gray-100`}>
|
||||
<div className={`sticky left-0 z-[60] ${NAME_COL_W} py-2 pl-4 pr-2 ${isChecked ? "bg-gray-50" : stickyCellBg} transition-colors group-hover:bg-gray-100/70`}>
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -576,6 +714,77 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
|
|||
);
|
||||
}
|
||||
|
||||
function WorkflowMetadata({ workflow }: { workflow: Workflow }) {
|
||||
const fields: { label: string; value: string }[] = [
|
||||
{ label: "Type", value: workflow.type === "tabular" ? "Tabular" : "Assistant" },
|
||||
{ label: "Source", value: getWorkflowSourceLabel(workflow) },
|
||||
];
|
||||
if (workflow.language) fields.push({ label: "Language", value: workflow.language });
|
||||
if (workflow.version) fields.push({ label: "Version", value: workflow.version });
|
||||
if (workflow.practice) fields.push({ label: "Practice", value: workflow.practice });
|
||||
if (workflow.jurisdictions?.length) {
|
||||
fields.push({ label: "Jurisdiction", value: workflow.jurisdictions.join(", ") });
|
||||
}
|
||||
if (workflow.open_source_submission) {
|
||||
const statusLabels: Record<
|
||||
NonNullable<Workflow["open_source_submission"]>["status"],
|
||||
string
|
||||
> = {
|
||||
pending: "Pending review",
|
||||
approved: "Approved",
|
||||
rejected: "Rejected",
|
||||
};
|
||||
fields.push({
|
||||
label: "Open source",
|
||||
value: statusLabels[workflow.open_source_submission.status],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-3 px-4 py-3 text-xs shrink-0 md:px-10">
|
||||
{fields.map(({ label, value }) => (
|
||||
<div key={label} className="flex flex-col gap-0.5">
|
||||
<span className="text-gray-400">{label}</span>
|
||||
<span className="text-gray-700">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowMetadataSkeleton() {
|
||||
const fields = [
|
||||
{ labelWidth: "w-8", valueWidth: "w-16" },
|
||||
{ labelWidth: "w-10", valueWidth: "w-14" },
|
||||
{ labelWidth: "w-12", valueWidth: "w-20" },
|
||||
{ labelWidth: "w-10", valueWidth: "w-12" },
|
||||
{ labelWidth: "w-12", valueWidth: "w-24" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-wrap gap-x-8 gap-y-3 px-4 py-3 md:px-10">
|
||||
{fields.map((field, index) => (
|
||||
<div key={index} className="flex flex-col gap-1">
|
||||
<div
|
||||
className={`h-2.5 ${field.labelWidth} animate-pulse rounded bg-gray-100`}
|
||||
/>
|
||||
<div
|
||||
className={`h-3 ${field.valueWidth} animate-pulse rounded bg-gray-100`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getWorkflowSourceLabel(workflow: Workflow) {
|
||||
if (workflow.is_system) return "System";
|
||||
if (workflow.is_owner === false) {
|
||||
return workflow.shared_by_name?.trim() || "Shared";
|
||||
}
|
||||
return "User";
|
||||
}
|
||||
|
||||
function AssistantWorkflowEditorSkeleton() {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 px-4 pb-2 pt-0 md:px-10 md:pb-3">
|
||||
|
|
|
|||
|
|
@ -1,190 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { Users } from "lucide-react";
|
||||
import { Modal } from "@/app/components/shared/Modal";
|
||||
import type { Workflow } from "@/app/components/shared/types";
|
||||
import { listWorkflowShares } from "@/app/lib/mikeApi";
|
||||
|
||||
interface WorkflowDetailsModalProps {
|
||||
open: boolean;
|
||||
workflow: Workflow | null;
|
||||
canEdit: boolean;
|
||||
canShare: boolean;
|
||||
currentUserDisplayName?: string | null;
|
||||
currentUserEmail?: string | null;
|
||||
onClose: () => void;
|
||||
onSave: (values: { title: string }) => Promise<void>;
|
||||
onShareWorkflow: () => void;
|
||||
}
|
||||
|
||||
export function WorkflowDetailsModal({
|
||||
open,
|
||||
workflow,
|
||||
canEdit,
|
||||
canShare,
|
||||
currentUserDisplayName,
|
||||
currentUserEmail,
|
||||
onClose,
|
||||
onSave,
|
||||
onShareWorkflow,
|
||||
}: WorkflowDetailsModalProps) {
|
||||
const [titleDraft, setTitleDraft] = useState("");
|
||||
const [shareCount, setShareCount] = useState<number | null>(null);
|
||||
const [sharesLoading, setSharesLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !workflow) return;
|
||||
setTitleDraft(workflow.title);
|
||||
setShareCount(null);
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
}, [open, workflow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !workflow || !canShare) {
|
||||
setSharesLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setSharesLoading(true);
|
||||
listWorkflowShares(workflow.id)
|
||||
.then((shares) => {
|
||||
if (!cancelled) setShareCount(shares.length);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setShareCount(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setSharesLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canShare, open, workflow]);
|
||||
|
||||
const trimmedTitle = titleDraft.trim();
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!workflow) return false;
|
||||
return trimmedTitle !== workflow.title;
|
||||
}, [trimmedTitle, workflow]);
|
||||
|
||||
if (!workflow) return null;
|
||||
|
||||
const typeLabel = workflow.type === "tabular" ? "Tabular" : "Assistant";
|
||||
const ownershipLabel = workflow.is_system
|
||||
? "Built-in"
|
||||
: workflow.is_owner === false
|
||||
? "Shared with you"
|
||||
: shareCount && shareCount > 0
|
||||
? "Shared"
|
||||
: "Private";
|
||||
const ownerLabel =
|
||||
workflow.is_owner === false
|
||||
? workflow.shared_by_name?.trim() || "Unknown"
|
||||
: currentUserDisplayName?.trim() ||
|
||||
currentUserEmail?.trim() ||
|
||||
"You";
|
||||
|
||||
async function handleSave() {
|
||||
if (!canEdit || saving || !hasChanges || !trimmedTitle) return;
|
||||
setSaving(true);
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave({ title: trimmedTitle });
|
||||
setSaved(true);
|
||||
} catch {
|
||||
setError("Could not update workflow details.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
breadcrumbs={["Workflows", workflow.title, "Details"]}
|
||||
secondaryAction={
|
||||
canShare
|
||||
? {
|
||||
label: "Share Workflow",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
onClick: onShareWorkflow,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
footerStatus={
|
||||
error ? (
|
||||
<span className="text-sm text-red-600">{error}</span>
|
||||
) : saved ? (
|
||||
<span className="text-sm text-gray-400">Updated</span>
|
||||
) : null
|
||||
}
|
||||
primaryAction={
|
||||
canEdit
|
||||
? {
|
||||
label: saving ? "Updating..." : "Update",
|
||||
onClick: () => void handleSave(),
|
||||
disabled: saving || !hasChanges || !trimmedTitle,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
cancelAction={canEdit ? undefined : false}
|
||||
>
|
||||
<div className="flex flex-col gap-5 py-1">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label
|
||||
htmlFor="workflow-details-title"
|
||||
className="text-xs font-medium text-gray-700"
|
||||
>
|
||||
Workflow Name
|
||||
</label>
|
||||
<input
|
||||
id="workflow-details-title"
|
||||
value={titleDraft}
|
||||
onChange={(e) => {
|
||||
setTitleDraft(e.target.value);
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={!canEdit || saving}
|
||||
className="h-9 w-full rounded-md border border-gray-200 bg-gray-50 px-3 text-sm text-gray-900 outline-none transition-colors focus:border-gray-300 disabled:cursor-not-allowed disabled:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-100 text-sm">
|
||||
<DetailRow label="Type" value={typeLabel} />
|
||||
<DetailRow
|
||||
label="Ownership"
|
||||
value={
|
||||
sharesLoading ? (
|
||||
<span className="inline-block h-4 w-14 rounded bg-gray-100 animate-pulse" />
|
||||
) : (
|
||||
ownershipLabel
|
||||
)
|
||||
}
|
||||
/>
|
||||
<DetailRow label="Owner" value={ownerLabel} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-3">
|
||||
<span className="text-gray-500">{label}</span>
|
||||
<span className="min-w-0 truncate text-right text-gray-900">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,13 +17,11 @@ import {
|
|||
unhideWorkflow,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import type { Workflow } from "../shared/types";
|
||||
import { BUILT_IN_WORKFLOWS, BUILT_IN_IDS } from "./builtinWorkflows";
|
||||
import { DisplayWorkflowModal } from "./DisplayWorkflowModal";
|
||||
import { UseWorkflowModal } from "./UseWorkflowModal";
|
||||
import { NewWorkflowModal } from "./NewWorkflowModal";
|
||||
import { TableToolbar } from "../shared/TableToolbar";
|
||||
import { RowActionMenuItems, RowActions } from "../shared/RowActions";
|
||||
import { MikeIcon } from "@/components/chat/mike-icon";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageHeader } from "@/app/components/shared/PageHeader";
|
||||
import { workflowDetailPath } from "./workflowRoutes";
|
||||
import {
|
||||
|
|
@ -47,24 +45,31 @@ import {
|
|||
TableStickyCell,
|
||||
} from "../shared/TablePrimitive";
|
||||
|
||||
type WorkflowScope = "all" | "builtin" | "custom" | "hidden";
|
||||
type WorkflowScope = "all" | "system" | "user" | "shared";
|
||||
|
||||
const WORKFLOW_SCOPES: { id: WorkflowScope; label: string }[] = [
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "builtin", label: "Built-in" },
|
||||
{ id: "custom", label: "Custom" },
|
||||
{ id: "hidden", label: "Hidden" },
|
||||
{ id: "user", label: "User" },
|
||||
{ id: "shared", label: "Shared with me" },
|
||||
{ id: "system", label: "System" },
|
||||
];
|
||||
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
const devLog = (...args: Parameters<typeof console.log>) => {
|
||||
if (isDev) console.log(...args);
|
||||
};
|
||||
|
||||
export function WorkflowList() {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const [custom, setCustom] = useState<Workflow[]>([]);
|
||||
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState<Workflow | null>(null);
|
||||
const [activeScope, setActiveScope] = useState<WorkflowScope>("all");
|
||||
const [newModalOpen, setNewModalOpen] = useState(false);
|
||||
const [hiddenBuiltinIds, setHiddenBuiltinIds] = useState<string[]>([]);
|
||||
const [editingWorkflow, setEditingWorkflow] = useState<Workflow | null>(
|
||||
null,
|
||||
);
|
||||
const [hiddenSystemIds, setHiddenSystemIds] = useState<string[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
const [practiceFilter, setPracticeFilter] = useState<string | null>(null);
|
||||
|
|
@ -81,18 +86,37 @@ export function WorkflowList() {
|
|||
listHiddenWorkflows(),
|
||||
])
|
||||
.then(([assistant, tabular, hidden]) => {
|
||||
setCustom([...assistant, ...tabular]);
|
||||
setHiddenBuiltinIds(hidden);
|
||||
devLog("[workflows/ui:list] loaded", {
|
||||
assistantCount: assistant.length,
|
||||
tabularCount: tabular.length,
|
||||
hiddenCount: hidden.length,
|
||||
assistantSample: assistant.slice(0, 5).map((workflow) => ({
|
||||
id: workflow.id,
|
||||
title: workflow.title,
|
||||
type: workflow.type,
|
||||
user_id: workflow.user_id,
|
||||
is_system: workflow.is_system,
|
||||
is_owner: workflow.is_owner,
|
||||
})),
|
||||
tabularSample: tabular.slice(0, 5).map((workflow) => ({
|
||||
id: workflow.id,
|
||||
title: workflow.title,
|
||||
type: workflow.type,
|
||||
user_id: workflow.user_id,
|
||||
is_system: workflow.is_system,
|
||||
is_owner: workflow.is_owner,
|
||||
})),
|
||||
});
|
||||
setWorkflows([...assistant, ...tabular]);
|
||||
setHiddenSystemIds(hidden);
|
||||
})
|
||||
.catch((error) => {
|
||||
devLog("[workflows/ui:list] failed; showing no workflows", error);
|
||||
setWorkflows([]);
|
||||
})
|
||||
.catch(() => setCustom([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds([]);
|
||||
setActionsOpen(false);
|
||||
}, [activeScope, practiceFilter, typeFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (
|
||||
|
|
@ -106,21 +130,30 @@ export function WorkflowList() {
|
|||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [actionsOpen]);
|
||||
|
||||
const hiddenBuiltins = BUILT_IN_WORKFLOWS.filter((wf) =>
|
||||
hiddenBuiltinIds.includes(wf.id),
|
||||
const systemWorkflows = workflows.filter((wf) => wf.is_system);
|
||||
const userWorkflows = workflows.filter(
|
||||
(wf) => !wf.is_system && wf.is_owner !== false,
|
||||
);
|
||||
const visibleBuiltins = BUILT_IN_WORKFLOWS.filter(
|
||||
(wf) => !hiddenBuiltinIds.includes(wf.id),
|
||||
const sharedWorkflows = workflows.filter(
|
||||
(wf) => !wf.is_system && wf.is_owner === false,
|
||||
);
|
||||
const all = [...visibleBuiltins, ...custom];
|
||||
const hiddenSystem = systemWorkflows.filter((wf) =>
|
||||
hiddenSystemIds.includes(wf.id),
|
||||
);
|
||||
const visibleSystem = systemWorkflows.filter(
|
||||
(wf) => !hiddenSystemIds.includes(wf.id),
|
||||
);
|
||||
const systemRows = [...visibleSystem, ...hiddenSystem];
|
||||
const activeRows = [...visibleSystem, ...userWorkflows, ...sharedWorkflows];
|
||||
const allRows = [...systemRows, ...userWorkflows, ...sharedWorkflows];
|
||||
const byScope =
|
||||
activeScope === "builtin"
|
||||
? visibleBuiltins
|
||||
: activeScope === "custom"
|
||||
? custom
|
||||
: activeScope === "hidden"
|
||||
? hiddenBuiltins
|
||||
: all;
|
||||
activeScope === "all"
|
||||
? activeRows
|
||||
: activeScope === "system"
|
||||
? systemRows
|
||||
: activeScope === "user"
|
||||
? userWorkflows
|
||||
: sharedWorkflows;
|
||||
const practices = Array.from(
|
||||
new Set(
|
||||
byScope.map((wf) => wf.practice).filter((p): p is string => !!p),
|
||||
|
|
@ -149,17 +182,37 @@ export function WorkflowList() {
|
|||
);
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
setSelectedIds([]);
|
||||
setActionsOpen(false);
|
||||
}
|
||||
|
||||
function handleScopeChange(scope: WorkflowScope) {
|
||||
setActiveScope(scope);
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
function handleTypeFilterChange(value: Workflow["type"] | null) {
|
||||
setTypeFilter(value);
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
function handlePracticeFilterChange(value: string | null) {
|
||||
setPracticeFilter(value);
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
async function handleHideWorkflow(id: string) {
|
||||
setHiddenBuiltinIds((prev) => [...prev, id]);
|
||||
setHiddenSystemIds((prev) => [...prev, id]);
|
||||
await hideWorkflow(id).catch(() => {
|
||||
setHiddenBuiltinIds((prev) => prev.filter((x) => x !== id));
|
||||
setHiddenSystemIds((prev) => prev.filter((x) => x !== id));
|
||||
});
|
||||
}
|
||||
|
||||
async function handleUnhideWorkflow(id: string) {
|
||||
setHiddenBuiltinIds((prev) => prev.filter((x) => x !== id));
|
||||
setHiddenSystemIds((prev) => prev.filter((x) => x !== id));
|
||||
await unhideWorkflow(id).catch(() => {
|
||||
setHiddenBuiltinIds((prev) => [...prev, id]);
|
||||
setHiddenSystemIds((prev) => [...prev, id]);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -167,22 +220,26 @@ export function WorkflowList() {
|
|||
const ids = [...selectedIds];
|
||||
setActionsOpen(false);
|
||||
setSelectedIds([]);
|
||||
const builtinIds = ids.filter((id) => BUILT_IN_IDS.has(id));
|
||||
const customIds = ids.filter((id) => !BUILT_IN_IDS.has(id));
|
||||
if (builtinIds.length > 0) {
|
||||
setHiddenBuiltinIds((prev) => [
|
||||
const systemIds = ids.filter(
|
||||
(id) => workflows.find((workflow) => workflow.id === id)?.is_system,
|
||||
);
|
||||
const customIds = ids.filter((id) => !systemIds.includes(id));
|
||||
if (systemIds.length > 0) {
|
||||
setHiddenSystemIds((prev) => [
|
||||
...prev,
|
||||
...builtinIds.filter((id) => !prev.includes(id)),
|
||||
...systemIds.filter((id) => !prev.includes(id)),
|
||||
]);
|
||||
await Promise.all(
|
||||
builtinIds.map((id) => hideWorkflow(id).catch(() => {})),
|
||||
systemIds.map((id) => hideWorkflow(id).catch(() => {})),
|
||||
);
|
||||
}
|
||||
if (customIds.length > 0) {
|
||||
await Promise.all(
|
||||
customIds.map((id) => deleteWorkflow(id).catch(() => {})),
|
||||
);
|
||||
setCustom((prev) => prev.filter((w) => !customIds.includes(w.id)));
|
||||
setWorkflows((prev) =>
|
||||
prev.filter((w) => !customIds.includes(w.id)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -190,7 +247,7 @@ export function WorkflowList() {
|
|||
const ids = [...selectedIds];
|
||||
setActionsOpen(false);
|
||||
setSelectedIds([]);
|
||||
setHiddenBuiltinIds((prev) => prev.filter((id) => !ids.includes(id)));
|
||||
setHiddenSystemIds((prev) => prev.filter((id) => !ids.includes(id)));
|
||||
await Promise.all(ids.map((id) => unhideWorkflow(id).catch(() => {})));
|
||||
}
|
||||
|
||||
|
|
@ -218,7 +275,7 @@ export function WorkflowList() {
|
|||
className,
|
||||
};
|
||||
})}
|
||||
onChange={setTypeFilter}
|
||||
onChange={handleTypeFilterChange}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -231,10 +288,22 @@ export function WorkflowList() {
|
|||
value: practice,
|
||||
label: practice,
|
||||
}))}
|
||||
onChange={setPracticeFilter}
|
||||
onChange={handlePracticeFilterChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const selectedHiddenSystemIds = selectedIds.filter((id) =>
|
||||
hiddenSystemIds.includes(id),
|
||||
);
|
||||
const selectedSystemIds = selectedIds.filter(
|
||||
(id) => workflows.find((workflow) => workflow.id === id)?.is_system,
|
||||
);
|
||||
const selectedOnlySystem =
|
||||
selectedIds.length > 0 && selectedIds.length === selectedSystemIds.length;
|
||||
const selectedOnlyHiddenSystem =
|
||||
selectedIds.length > 0 &&
|
||||
selectedIds.length === selectedHiddenSystemIds.length;
|
||||
|
||||
const toolbarActions =
|
||||
selectedIds.length > 0 ? (
|
||||
<div ref={actionsRef} className="relative">
|
||||
|
|
@ -247,19 +316,19 @@ export function WorkflowList() {
|
|||
</button>
|
||||
{actionsOpen && (
|
||||
<div className={`absolute top-full right-0 mt-1 z-[100] w-36 overflow-hidden ${GLASS_DROPDOWN}`}>
|
||||
{activeScope === "hidden" ? (
|
||||
{selectedOnlyHiddenSystem ? (
|
||||
<button
|
||||
onClick={handleBulkUnhide}
|
||||
className={`w-full px-3 py-1.5 text-left text-xs text-gray-700 ${GLASS_MENU_ITEM}`}
|
||||
>
|
||||
Unhide
|
||||
Activate
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleBulkRemove}
|
||||
className="w-full px-3 py-1.5 text-left text-xs text-red-600 transition-colors hover:bg-red-500/10"
|
||||
>
|
||||
Delete
|
||||
{selectedOnlySystem ? "Deactivate" : "Delete"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -295,14 +364,14 @@ export function WorkflowList() {
|
|||
<TableToolbar
|
||||
items={WORKFLOW_SCOPES}
|
||||
active={activeScope}
|
||||
onChange={setActiveScope}
|
||||
onChange={handleScopeChange}
|
||||
actions={toolbarActions}
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
<TableScrollArea>
|
||||
{/* Column headers */}
|
||||
<TableHeaderRow>
|
||||
<TableScrollArea
|
||||
header={
|
||||
<TableHeaderRow>
|
||||
<TableStickyCell header>
|
||||
{loading ? (
|
||||
<SkeletonDot />
|
||||
|
|
@ -331,11 +400,13 @@ export function WorkflowList() {
|
|||
{practiceFilterButton}
|
||||
</div>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="w-28">Source</TableHeaderCell>
|
||||
<TableHeaderCell className="w-44">Source</TableHeaderCell>
|
||||
<TableHeaderCell className="w-8" />
|
||||
</TableHeaderRow>
|
||||
</TableHeaderRow>
|
||||
}
|
||||
>
|
||||
|
||||
{loading && activeScope !== "builtin" ? (
|
||||
{loading ? (
|
||||
<TableBody>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<TableRow
|
||||
|
|
@ -356,7 +427,7 @@ export function WorkflowList() {
|
|||
<TableCell className="w-40">
|
||||
<SkeletonLine className="w-24" />
|
||||
</TableCell>
|
||||
<TableCell className="w-28">
|
||||
<TableCell className="w-44">
|
||||
<SkeletonLine className="w-14" />
|
||||
</TableCell>
|
||||
<TableCell className="w-8" />
|
||||
|
|
@ -365,11 +436,11 @@ export function WorkflowList() {
|
|||
</TableBody>
|
||||
) : filtered.length === 0 ? (
|
||||
<TableEmptyState>
|
||||
{activeScope === "custom" ? (
|
||||
{activeScope === "user" ? (
|
||||
<>
|
||||
<Library className="h-8 w-8 text-gray-300 mb-4" />
|
||||
<p className="text-2xl font-medium font-serif text-gray-900">
|
||||
Custom Workflows
|
||||
User Workflows
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-400 text-left">
|
||||
Build reusable prompts and tabular
|
||||
|
|
@ -383,16 +454,15 @@ export function WorkflowList() {
|
|||
+ Create New
|
||||
</button>
|
||||
</>
|
||||
) : activeScope === "hidden" ? (
|
||||
) : activeScope === "shared" ? (
|
||||
<>
|
||||
<Library className="h-8 w-8 text-gray-300 mb-4" />
|
||||
<p className="text-2xl font-medium font-serif text-gray-900">
|
||||
Hidden Workflows
|
||||
Shared Workflows
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-400 text-left">
|
||||
Built-in workflows you've hidden will
|
||||
appear here. You can unhide them at any
|
||||
time.
|
||||
Workflows shared with you by other users
|
||||
will appear here.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -411,15 +481,17 @@ export function WorkflowList() {
|
|||
) : (
|
||||
<TableBody>
|
||||
{filtered.map((wf) => {
|
||||
const isHiddenSystem = hiddenSystemIds.includes(wf.id);
|
||||
const rowBg = selectedIds.includes(wf.id)
|
||||
? "bg-gray-50"
|
||||
: TABLE_STICKY_CELL_BG;
|
||||
return (
|
||||
<TableRow
|
||||
key={wf.id}
|
||||
className={isHiddenSystem ? "opacity-45" : undefined}
|
||||
rightClickDropdown={
|
||||
wf.is_system
|
||||
? activeScope === "hidden"
|
||||
? isHiddenSystem
|
||||
? (close) => (
|
||||
<RowActionMenuItems
|
||||
onClose={close}
|
||||
|
|
@ -445,11 +517,14 @@ export function WorkflowList() {
|
|||
: (close) => (
|
||||
<RowActionMenuItems
|
||||
onClose={close}
|
||||
onEditDetails={() =>
|
||||
setEditingWorkflow(wf)
|
||||
}
|
||||
onDelete={async () => {
|
||||
await deleteWorkflow(
|
||||
wf.id,
|
||||
);
|
||||
setCustom((prev) =>
|
||||
setWorkflows((prev) =>
|
||||
prev.filter(
|
||||
(w) =>
|
||||
w.id !==
|
||||
|
|
@ -493,22 +568,22 @@ export function WorkflowList() {
|
|||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="w-28">
|
||||
<TableCell className="w-44">
|
||||
{wf.is_system ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-gray-600">
|
||||
<MikeIcon size={14} />
|
||||
Mike
|
||||
System
|
||||
</span>
|
||||
) : wf.user_id === user?.id ? (
|
||||
) : wf.is_owner !== false ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-gray-600">
|
||||
<User className="h-3.5 w-3.5 text-gray-500" />
|
||||
Myself
|
||||
User
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-gray-600 truncate max-w-full">
|
||||
<User className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||
<span className="truncate">
|
||||
{wf.shared_by_name ?? "Shared"}
|
||||
{getSharedByLabel(wf)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -518,7 +593,7 @@ export function WorkflowList() {
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{wf.is_system ? (
|
||||
activeScope === "hidden" ? (
|
||||
isHiddenSystem ? (
|
||||
<RowActions
|
||||
onUnhide={() =>
|
||||
handleUnhideWorkflow(wf.id)
|
||||
|
|
@ -533,9 +608,12 @@ export function WorkflowList() {
|
|||
)
|
||||
) : wf.is_owner === false ? null : (
|
||||
<RowActions
|
||||
onEditDetails={() =>
|
||||
setEditingWorkflow(wf)
|
||||
}
|
||||
onDelete={async () => {
|
||||
await deleteWorkflow(wf.id);
|
||||
setCustom((prev) =>
|
||||
setWorkflows((prev) =>
|
||||
prev.filter(
|
||||
(w) => w.id !== wf.id,
|
||||
),
|
||||
|
|
@ -551,8 +629,8 @@ export function WorkflowList() {
|
|||
)}
|
||||
</TableScrollArea>
|
||||
|
||||
<DisplayWorkflowModal
|
||||
workflows={all}
|
||||
<UseWorkflowModal
|
||||
workflows={allRows}
|
||||
workflow={selected}
|
||||
onClose={() => setSelected(null)}
|
||||
/>
|
||||
|
|
@ -561,11 +639,32 @@ export function WorkflowList() {
|
|||
open={newModalOpen}
|
||||
onClose={() => setNewModalOpen(false)}
|
||||
onCreated={(wf) => {
|
||||
setCustom((prev) => [wf, ...prev]);
|
||||
setWorkflows((prev) => [wf, ...prev]);
|
||||
setNewModalOpen(false);
|
||||
router.push(workflowDetailPath(wf));
|
||||
}}
|
||||
/>
|
||||
|
||||
<NewWorkflowModal
|
||||
open={!!editingWorkflow}
|
||||
onClose={() => setEditingWorkflow(null)}
|
||||
onCreated={() => undefined}
|
||||
editWorkflow={editingWorkflow ?? undefined}
|
||||
onUpdated={(updated) => {
|
||||
setWorkflows((prev) =>
|
||||
prev.map((workflow) =>
|
||||
workflow.id === updated.id
|
||||
? { ...workflow, ...updated }
|
||||
: workflow,
|
||||
),
|
||||
);
|
||||
setEditingWorkflow(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getSharedByLabel(workflow: Workflow) {
|
||||
return workflow.shared_by_name?.trim() || "Shared";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
MessageSquare,
|
||||
Search,
|
||||
Table2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { ChevronDown, MessageSquare, Table2, X } from "lucide-react";
|
||||
import { SearchBar } from "@/components/ui/search-bar";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { ColumnConfig, Workflow } from "../shared/types";
|
||||
|
|
@ -47,9 +42,23 @@ export function WorkflowPickerContent({
|
|||
allowClearPreview = true,
|
||||
}: WorkflowPickerContentProps) {
|
||||
const selectedRowRef = useRef<HTMLButtonElement>(null);
|
||||
const [mobilePane, setMobilePane] = useState<MobilePickerPane>(
|
||||
selected ? "details" : "list",
|
||||
);
|
||||
const selectedId = selected?.id ?? null;
|
||||
const [mobilePaneState, setMobilePaneState] = useState<{
|
||||
selectedId: string | null;
|
||||
pane: MobilePickerPane;
|
||||
}>({
|
||||
selectedId,
|
||||
pane: selected ? "details" : "list",
|
||||
});
|
||||
const mobilePane =
|
||||
mobilePaneState.selectedId === selectedId
|
||||
? mobilePaneState.pane
|
||||
: selected
|
||||
? "details"
|
||||
: "list";
|
||||
const setMobilePane = (pane: MobilePickerPane) => {
|
||||
setMobilePaneState({ selectedId, pane });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRowRef.current) {
|
||||
|
|
@ -57,17 +66,13 @@ export function WorkflowPickerContent({
|
|||
}
|
||||
}, [selected?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setMobilePane(selected ? "details" : "list");
|
||||
}, [selected?.id]);
|
||||
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
const filteredWorkflows = normalizedSearch
|
||||
? workflows.filter((workflow) =>
|
||||
[
|
||||
workflow.title,
|
||||
workflow.practice ?? "",
|
||||
workflow.is_system ? "Built-in" : "Custom",
|
||||
workflow.is_system ? "System" : "Custom",
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
|
|
@ -91,44 +96,26 @@ export function WorkflowPickerContent({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden md:flex-row">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3 overflow-visible md:flex-row">
|
||||
<div
|
||||
className={`min-h-0 flex-1 flex-col overflow-hidden ${
|
||||
selected ? "md:w-80 md:flex-none md:shrink-0" : ""
|
||||
className={`min-h-0 min-w-0 flex-1 flex-col overflow-visible ${
|
||||
selected ? "md:w-64 md:flex-none md:shrink-0" : ""
|
||||
} ${mobilePane === "details" && selected ? "hidden md:flex" : "flex"}`}
|
||||
>
|
||||
<div className="shrink-0 pb-2 pt-3">
|
||||
<div className="flex h-9 items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3">
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search workflows..."
|
||||
value={search}
|
||||
onChange={(event) =>
|
||||
onSearchChange(event.target.value)
|
||||
}
|
||||
className="flex-1 bg-transparent text-sm text-gray-700 outline-none placeholder:text-gray-400"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchChange("")}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SearchBar
|
||||
value={search}
|
||||
onValueChange={onSearchChange}
|
||||
placeholder="Search workflows..."
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-md border border-gray-200 bg-white">
|
||||
<div className="min-h-0 min-w-0 flex-1 rounded-sm pt-2">
|
||||
{loading ? (
|
||||
<div>
|
||||
<div className="space-y-px">
|
||||
{[60, 45, 75, 50, 65, 40, 55].map(
|
||||
(width, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2.5"
|
||||
className="flex items-center justify-between gap-3 rounded-md px-3 py-2.5"
|
||||
>
|
||||
<div
|
||||
className="h-3 animate-pulse rounded bg-gray-100"
|
||||
|
|
@ -144,7 +131,7 @@ export function WorkflowPickerContent({
|
|||
{resolvedEmptyMessage}
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
<div className="space-y-px">
|
||||
{filteredWorkflows.map((workflow) => {
|
||||
const disabled =
|
||||
disabledWorkflow?.(workflow) ?? false;
|
||||
|
|
@ -164,14 +151,14 @@ export function WorkflowPickerContent({
|
|||
isSelected ? null : workflow,
|
||||
)
|
||||
}
|
||||
className={`flex w-full items-center gap-3 px-3 py-2 text-left text-xs transition-colors ${
|
||||
className={`flex min-w-0 w-full items-center gap-3 rounded-md px-3 py-2 text-left text-xs transition-all ${
|
||||
isSelected
|
||||
? "bg-gray-50 text-gray-900"
|
||||
: "hover:bg-gray-50"
|
||||
? "bg-gray-100 text-gray-900"
|
||||
: "hover:bg-gray-100/70"
|
||||
} ${disabled ? "cursor-not-allowed opacity-45" : ""}`}
|
||||
>
|
||||
<span
|
||||
className={`flex-1 truncate ${
|
||||
className={`min-w-0 flex-1 truncate ${
|
||||
isSelected
|
||||
? "font-medium text-gray-900"
|
||||
: "text-gray-700"
|
||||
|
|
@ -184,7 +171,7 @@ export function WorkflowPickerContent({
|
|||
) : (
|
||||
<span className="shrink-0 text-xs text-gray-400">
|
||||
{workflow.is_system
|
||||
? "Built-in"
|
||||
? "System"
|
||||
: "Custom"}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -232,32 +219,36 @@ function WorkflowPreview({
|
|||
: mode;
|
||||
return (
|
||||
<div
|
||||
className={`${className} min-h-0 flex-1 flex-col overflow-hidden pt-3`}
|
||||
className={`${className} min-h-0 min-w-0 flex-1 flex-col overflow-visible`}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-gray-200 bg-white">
|
||||
<div className="flex h-10 shrink-0 items-center justify-between border-b border-gray-200 bg-white px-3">
|
||||
<p className="truncate text-sm font-medium text-gray-700">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col rounded-2xl border border-white/70 bg-white/55 shadow-[0_3px_9px_rgba(15,23,42,0.06),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-1px_0_rgba(255,255,255,0.58)] backdrop-blur-xl">
|
||||
<div className="flex h-9 shrink-0 items-center justify-between px-3">
|
||||
<p className="min-w-0 flex-1 truncate text-xs font-medium text-gray-700">
|
||||
{workflow.title}
|
||||
</p>
|
||||
{allowClear ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="rounded-md p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
|
||||
className="rounded-md p-1 text-gray-400 transition-colors hover:bg-gray-100/70 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{resolvedMode === "columns" ? (
|
||||
<WorkflowColumnPreview
|
||||
columns={workflow.columns_config ?? []}
|
||||
/>
|
||||
) : (
|
||||
<WorkflowPromptPreview
|
||||
content={workflow.prompt_md ?? "_No prompt defined._"}
|
||||
/>
|
||||
)}
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{resolvedMode === "columns" ? (
|
||||
<WorkflowColumnPreview
|
||||
columns={workflow.columns_config ?? []}
|
||||
/>
|
||||
) : (
|
||||
<WorkflowPromptPreview
|
||||
content={
|
||||
workflow.prompt_md ?? "_No prompt defined._"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -267,7 +258,7 @@ function WorkflowPromptPreview({ content }: { content: string }) {
|
|||
const previewContent = stripLeadingMarkdownHeading(content);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-gray-50 px-4 py-3 font-serif text-sm leading-relaxed text-gray-600">
|
||||
<div className="min-w-0 flex-1 break-words rounded-md px-3 py-3 font-serif text-sm leading-relaxed text-gray-600">
|
||||
<WorkflowPromptMarkdown content={previewContent} />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -312,6 +303,34 @@ function WorkflowPromptMarkdown({ content }: { content: string }) {
|
|||
</ol>
|
||||
),
|
||||
li: ({ children }) => <li>{children}</li>,
|
||||
table: ({ children }) => (
|
||||
<div className="my-3 overflow-x-auto rounded-md border border-gray-200 first:mt-0 last:mb-0">
|
||||
<table className="min-w-full border-collapse text-left text-xs">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-gray-50">{children}</thead>
|
||||
),
|
||||
tbody: ({ children }) => (
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{children}
|
||||
</tbody>
|
||||
),
|
||||
tr: ({ children }) => (
|
||||
<tr className="divide-x divide-gray-100">{children}</tr>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="px-3 py-2 font-medium text-gray-700">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-3 py-2 align-top text-gray-600">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold text-gray-800">
|
||||
{children}
|
||||
|
|
@ -329,7 +348,7 @@ function WorkflowColumnPreview({ columns }: { columns: ColumnConfig[] }) {
|
|||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||
const sortedColumns = [...columns].sort((a, b) => a.index - b.index);
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-gray-50">
|
||||
<div className="min-w-0 flex-1 space-y-px rounded-sm">
|
||||
{sortedColumns.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-xs text-gray-400">
|
||||
No columns defined
|
||||
|
|
@ -339,10 +358,7 @@ function WorkflowColumnPreview({ columns }: { columns: ColumnConfig[] }) {
|
|||
const isExpanded = expandedIndex === column.index;
|
||||
const FormatIcon = formatIcon(column.format ?? "text");
|
||||
return (
|
||||
<div
|
||||
key={column.index}
|
||||
className="border-b border-gray-200 last:border-b-0"
|
||||
>
|
||||
<div key={column.index} className="rounded-md">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
|
|
@ -350,13 +366,17 @@ function WorkflowColumnPreview({ columns }: { columns: ColumnConfig[] }) {
|
|||
isExpanded ? null : column.index,
|
||||
)
|
||||
}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left text-xs transition-colors hover:bg-white"
|
||||
className={`flex min-w-0 w-full items-center gap-2.5 rounded-md px-3 py-2.5 text-left text-xs transition-all ${
|
||||
isExpanded
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-100/70"
|
||||
}`}
|
||||
>
|
||||
<FormatIcon className="h-3.5 w-3.5 shrink-0 text-gray-400" />
|
||||
<span className="flex-1 truncate text-gray-800">
|
||||
<span className="min-w-0 flex-1 truncate text-gray-800">
|
||||
{column.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-gray-400">
|
||||
<span className="max-w-24 shrink-0 truncate text-gray-400">
|
||||
{formatLabel(column.format ?? "text")}
|
||||
</span>
|
||||
<ChevronDown
|
||||
|
|
@ -364,21 +384,23 @@ function WorkflowColumnPreview({ columns }: { columns: ColumnConfig[] }) {
|
|||
/>
|
||||
</button>
|
||||
{isExpanded ? (
|
||||
<div className="space-y-3 border-t border-gray-200 bg-white px-4 py-3 font-serif text-sm leading-relaxed text-gray-600">
|
||||
<div className="mt-1 min-w-0 space-y-3 break-words rounded-md bg-white/60 px-4 py-3 font-serif text-sm leading-relaxed text-gray-600">
|
||||
{column.tags && column.tags.length > 0 ? (
|
||||
<div>
|
||||
<p className="mb-1.5 font-sans text-[11px] font-medium text-gray-600">
|
||||
Tags
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{column.tags.map((tag, tagIdx) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={`inline-block rounded-full px-1.5 py-0.5 font-sans text-[10px] ${TAG_COLORS[tagIdx % TAG_COLORS.length]}`}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{column.tags.map(
|
||||
(tag, tagIdx) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={`inline-block rounded-full px-1.5 py-0.5 font-sans text-[10px] ${TAG_COLORS[tagIdx % TAG_COLORS.length]}`}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,15 @@
|
|||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { listWorkflows } from "@/app/lib/mikeApi";
|
||||
import { Modal } from "../shared/Modal";
|
||||
import { Modal } from "../modals/Modal";
|
||||
import type { Workflow } from "../shared/types";
|
||||
import { BUILT_IN_WORKFLOWS } from "./builtinWorkflows";
|
||||
import { WorkflowPickerContent } from "./WorkflowPickerContent";
|
||||
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
const devLog = (...args: Parameters<typeof console.log>) => {
|
||||
if (isDev) console.log(...args);
|
||||
};
|
||||
|
||||
interface WorkflowPickerModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
|
|
@ -42,44 +46,49 @@ export function WorkflowPickerModal({
|
|||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
const builtins = BUILT_IN_WORKFLOWS.filter(
|
||||
(workflow) => workflow.type === workflowType,
|
||||
);
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
setWorkflows(builtins);
|
||||
setWorkflows([]);
|
||||
setLoading(true);
|
||||
setSelected(
|
||||
initialWorkflowId
|
||||
? builtins.find((workflow) => workflow.id === initialWorkflowId) ??
|
||||
null
|
||||
: null,
|
||||
);
|
||||
setSelected(null);
|
||||
setSearch("");
|
||||
});
|
||||
|
||||
listWorkflows(workflowType)
|
||||
.then((custom) => {
|
||||
.then((workflows) => {
|
||||
if (cancelled) return;
|
||||
const all = [...builtins, ...custom];
|
||||
setWorkflows(all);
|
||||
devLog("[workflows/ui:picker] loaded", {
|
||||
workflowType,
|
||||
workflowCount: workflows.length,
|
||||
systemCount: workflows.filter((workflow) => workflow.is_system)
|
||||
.length,
|
||||
sample: workflows.slice(0, 5).map((workflow) => ({
|
||||
id: workflow.id,
|
||||
title: workflow.title,
|
||||
type: workflow.type,
|
||||
user_id: workflow.user_id,
|
||||
is_system: workflow.is_system,
|
||||
is_owner: workflow.is_owner,
|
||||
})),
|
||||
});
|
||||
setWorkflows(workflows);
|
||||
if (initialWorkflowId) {
|
||||
setSelected(
|
||||
all.find((workflow) => workflow.id === initialWorkflowId) ??
|
||||
null,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
if (initialWorkflowId) {
|
||||
setSelected(
|
||||
builtins.find(
|
||||
workflows.find(
|
||||
(workflow) => workflow.id === initialWorkflowId,
|
||||
) ?? null,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
devLog("[workflows/ui:picker] failed", {
|
||||
workflowType,
|
||||
error,
|
||||
});
|
||||
setWorkflows([]);
|
||||
setSelected(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { TableKit } from "@tiptap/extension-table";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
|
|
@ -66,6 +67,11 @@ export function WorkflowPromptEditor({
|
|||
blockquote: false,
|
||||
horizontalRule: false,
|
||||
}),
|
||||
TableKit.configure({
|
||||
table: {
|
||||
renderWrapper: true,
|
||||
},
|
||||
}),
|
||||
Markdown.configure({
|
||||
html: false,
|
||||
transformPastedText: true,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -17,7 +17,7 @@ export const PRACTICE_OPTIONS = [
|
|||
"DCM",
|
||||
"Lev Fin",
|
||||
"Arbitration",
|
||||
"Others",
|
||||
"Other",
|
||||
] as const;
|
||||
|
||||
export type Practice = (typeof PRACTICE_OPTIONS)[number];
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { PillButton } from "@/components/ui/pill-button";
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
|
|
@ -23,12 +24,9 @@ export default function Error({
|
|||
our team will look into it.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full text-sm font-medium text-white bg-gray-900 hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<PillButton asChild tone="black" size="normal">
|
||||
<Link href="/">Home</Link>
|
||||
</PillButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { PillButton } from "@/components/ui/pill-button";
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
|
|
@ -51,30 +52,7 @@ export default function GlobalError({
|
|||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
font-family: 'Inter', sans-serif;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
background-color: rgb(0, 136, 255);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background-color: rgb(0, 120, 230);
|
||||
}
|
||||
|
||||
.btn-back:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.btn-back { font-family: 'Inter', sans-serif; }
|
||||
`}</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -84,12 +62,14 @@ export default function GlobalError({
|
|||
We encountered an unexpected error. This has been logged
|
||||
and our team will look into it.
|
||||
</p>
|
||||
<button
|
||||
<PillButton
|
||||
tone="blue"
|
||||
size="normal"
|
||||
className="btn-back"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</PillButton>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -343,27 +343,30 @@
|
|||
}
|
||||
|
||||
.workflow-editor-content h1 {
|
||||
font-size: 1rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.375rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.workflow-editor-content h2 {
|
||||
font-size: 0.875rem;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-top: 0.875rem;
|
||||
margin-bottom: 0.25rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.workflow-editor-content h3 {
|
||||
font-size: 0.8125rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin-top: 0.625rem;
|
||||
margin-bottom: 0.125rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.workflow-editor-content h1:first-child,
|
||||
|
|
@ -409,6 +412,63 @@
|
|||
font-style: italic;
|
||||
}
|
||||
|
||||
.workflow-editor-content .tableWrapper {
|
||||
margin: 0.75rem 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.workflow-editor-content .tableWrapper:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.workflow-editor-content .tableWrapper:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.workflow-editor-content table {
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.workflow-editor-content thead {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.workflow-editor-content th,
|
||||
.workflow-editor-content td {
|
||||
border-right: 1px solid #f3f4f6;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
padding: 0.5rem 0.75rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.workflow-editor-content th:last-child,
|
||||
.workflow-editor-content td:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.workflow-editor-content tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.workflow-editor-content th {
|
||||
color: #374151;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.workflow-editor-content td {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.workflow-editor-content th p,
|
||||
.workflow-editor-content td p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ChatBox Animation Styles */
|
||||
|
||||
/* PDF text layer */
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
|
|||
import { useGenerateChatTitle } from "./useGenerateChatTitle";
|
||||
import type {
|
||||
AssistantEvent,
|
||||
CitationAnnotation,
|
||||
Citation,
|
||||
Message,
|
||||
} from "@/app/components/shared/types";
|
||||
|
||||
|
|
@ -99,6 +99,21 @@ export function useAssistantChat({
|
|||
|
||||
const eventsRef = useRef<AssistantEvent[]>([]);
|
||||
|
||||
const updateLatestAssistantMessage = (
|
||||
updater: (message: Message) => Message,
|
||||
) => {
|
||||
setMessages((prev) => {
|
||||
const assistantIndex = [...prev]
|
||||
.map((message, index) => ({ message, index }))
|
||||
.reverse()
|
||||
.find(({ message }) => message.role === "assistant")?.index;
|
||||
if (assistantIndex === undefined) return prev;
|
||||
const updated = [...prev];
|
||||
updated[assistantIndex] = updater(updated[assistantIndex]);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Finalize any in-flight streaming content event so the next
|
||||
* content_delta starts a fresh block. Called
|
||||
|
|
@ -115,17 +130,10 @@ export function useAssistantChat({
|
|||
{ type: "content", text: last.text },
|
||||
];
|
||||
const snapshot = [...eventsRef.current];
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const lastMsg = updated[updated.length - 1];
|
||||
if (lastMsg?.role === "assistant") {
|
||||
updated[updated.length - 1] = {
|
||||
...lastMsg,
|
||||
events: snapshot,
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
updateLatestAssistantMessage((message) => ({
|
||||
...message,
|
||||
events: snapshot,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -141,17 +149,10 @@ export function useAssistantChat({
|
|||
{ type: "reasoning", text: last.text },
|
||||
];
|
||||
const snapshot = [...eventsRef.current];
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const lastMsg = updated[updated.length - 1];
|
||||
if (lastMsg?.role === "assistant") {
|
||||
updated[updated.length - 1] = {
|
||||
...lastMsg,
|
||||
events: snapshot,
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
updateLatestAssistantMessage((message) => ({
|
||||
...message,
|
||||
events: snapshot,
|
||||
}));
|
||||
};
|
||||
|
||||
// Transient placeholder events (tool_call_start, thinking) fill the
|
||||
|
|
@ -183,17 +184,10 @@ export function useAssistantChat({
|
|||
abortControllerRef.current.abort();
|
||||
const snapshot = cancelStreamingEvents(eventsRef.current);
|
||||
eventsRef.current = snapshot;
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last?.role === "assistant") {
|
||||
updated[updated.length - 1] = {
|
||||
...last,
|
||||
events: cancelStreamingEvents(last.events ?? snapshot),
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
updateLatestAssistantMessage((message) => ({
|
||||
...message,
|
||||
events: cancelStreamingEvents(message.events ?? snapshot),
|
||||
}));
|
||||
setIsResponseLoading(false);
|
||||
setIsLoadingCitations(false);
|
||||
}
|
||||
|
|
@ -205,14 +199,7 @@ export function useAssistantChat({
|
|||
if (after.length === before.length) return;
|
||||
eventsRef.current = after;
|
||||
const snapshot = [...after];
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last?.role === "assistant") {
|
||||
updated[updated.length - 1] = { ...last, events: snapshot };
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
updateLatestAssistantMessage((message) => ({ ...message, events: snapshot }));
|
||||
};
|
||||
|
||||
const pushThinkingPlaceholder = () => {
|
||||
|
|
@ -225,14 +212,7 @@ export function useAssistantChat({
|
|||
{ type: "thinking" as const, isStreaming: true },
|
||||
];
|
||||
const snapshot = [...eventsRef.current];
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const lastMsg = updated[updated.length - 1];
|
||||
if (lastMsg?.role === "assistant") {
|
||||
updated[updated.length - 1] = { ...lastMsg, events: snapshot };
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
updateLatestAssistantMessage((message) => ({ ...message, events: snapshot }));
|
||||
};
|
||||
|
||||
const pushEvent = (event: AssistantEvent) => {
|
||||
|
|
@ -243,14 +223,7 @@ export function useAssistantChat({
|
|||
const next = eventsRef.current.filter((e) => !isStreamingPlaceholder(e));
|
||||
eventsRef.current = [...next, event];
|
||||
const snapshot = [...eventsRef.current];
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last?.role === "assistant") {
|
||||
updated[updated.length - 1] = { ...last, events: snapshot };
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
updateLatestAssistantMessage((message) => ({ ...message, events: snapshot }));
|
||||
};
|
||||
|
||||
const updateMatchingEvent = (
|
||||
|
|
@ -267,14 +240,7 @@ export function useAssistantChat({
|
|||
newEvents[idx] = updater(events[idx]);
|
||||
eventsRef.current = newEvents;
|
||||
const snapshot = [...newEvents];
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last?.role === "assistant") {
|
||||
updated[updated.length - 1] = { ...last, events: snapshot };
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
updateLatestAssistantMessage((message) => ({ ...message, events: snapshot }));
|
||||
return true;
|
||||
};
|
||||
|
||||
|
|
@ -282,6 +248,10 @@ export function useAssistantChat({
|
|||
message: Message,
|
||||
opts?: {
|
||||
displayedDoc?: { filename: string; documentId: string } | null;
|
||||
askInputsResponse?: Extract<
|
||||
AssistantEvent,
|
||||
{ type: "ask_inputs_response" }
|
||||
>;
|
||||
},
|
||||
): Promise<string | null> => {
|
||||
if (!message.content.trim()) return null;
|
||||
|
|
@ -294,24 +264,62 @@ export function useAssistantChat({
|
|||
lastMessage.role === "user" &&
|
||||
lastMessage.content === message.content;
|
||||
|
||||
const newMessages: Message[] = isMessageAlreadyAdded
|
||||
const apiMessagesForTurn: Message[] = isMessageAlreadyAdded
|
||||
? messages
|
||||
: [...messages, message];
|
||||
const askInputsResponseEvent = opts?.askInputsResponse ?? null;
|
||||
const optimisticResponseEvent = askInputsResponseEvent;
|
||||
const userInputThinkingEvent = optimisticResponseEvent
|
||||
? ({
|
||||
type: "thinking" as const,
|
||||
isStreaming: true,
|
||||
} satisfies AssistantEvent)
|
||||
: null;
|
||||
const displayMessages: Message[] = optimisticResponseEvent
|
||||
? (() => {
|
||||
const updated = messages.map((item) => ({
|
||||
...item,
|
||||
events: item.events ? [...item.events] : item.events,
|
||||
}));
|
||||
for (let i = updated.length - 1; i >= 0; i--) {
|
||||
const current = updated[i];
|
||||
if (current.role !== "assistant") continue;
|
||||
updated[i] = {
|
||||
...current,
|
||||
events: [
|
||||
...(current.events ?? []),
|
||||
optimisticResponseEvent,
|
||||
...(userInputThinkingEvent ? [userInputThinkingEvent] : []),
|
||||
],
|
||||
};
|
||||
return updated;
|
||||
}
|
||||
return updated;
|
||||
})()
|
||||
: apiMessagesForTurn;
|
||||
|
||||
setMessages([
|
||||
...newMessages,
|
||||
{ role: "assistant", content: "", annotations: [], events: [] },
|
||||
]);
|
||||
setMessages(
|
||||
optimisticResponseEvent
|
||||
? displayMessages
|
||||
: [
|
||||
...displayMessages,
|
||||
{ role: "assistant", content: "", citations: [], events: [] },
|
||||
],
|
||||
);
|
||||
|
||||
let streamedChatId: string | null = null;
|
||||
|
||||
eventsRef.current = [];
|
||||
eventsRef.current = optimisticResponseEvent
|
||||
? ([...displayMessages]
|
||||
.reverse()
|
||||
.find((item) => item.role === "assistant")?.events ?? [])
|
||||
: [];
|
||||
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
try {
|
||||
const apiMessages = newMessages.map((currentMessage) => ({
|
||||
const apiMessages = apiMessagesForTurn.map((currentMessage) => ({
|
||||
role: currentMessage.role,
|
||||
content: currentMessage.content,
|
||||
files: currentMessage.files,
|
||||
|
|
@ -348,12 +356,14 @@ export function useAssistantChat({
|
|||
: undefined,
|
||||
attached_documents:
|
||||
attachedDocs.length > 0 ? attachedDocs : undefined,
|
||||
ask_inputs_response: opts?.askInputsResponse,
|
||||
signal: controller.signal,
|
||||
})
|
||||
: streamChat({
|
||||
messages: apiMessages,
|
||||
chat_id: chatId,
|
||||
model,
|
||||
ask_inputs_response: opts?.askInputsResponse,
|
||||
signal: controller.signal,
|
||||
}));
|
||||
|
||||
|
|
@ -408,18 +418,11 @@ export function useAssistantChat({
|
|||
{ type: "error", message },
|
||||
];
|
||||
const snapshot = [...eventsRef.current];
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last?.role === "assistant") {
|
||||
updated[updated.length - 1] = {
|
||||
...last,
|
||||
events: snapshot,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
updateLatestAssistantMessage((assistantMessage) => ({
|
||||
...assistantMessage,
|
||||
events: snapshot,
|
||||
error: message,
|
||||
}));
|
||||
setIsResponseLoading(false);
|
||||
setIsLoadingCitations(false);
|
||||
continue;
|
||||
|
|
@ -451,17 +454,10 @@ export function useAssistantChat({
|
|||
},
|
||||
];
|
||||
const snapshot = [...eventsRef.current];
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last?.role === "assistant") {
|
||||
updated[updated.length - 1] = {
|
||||
...last,
|
||||
events: snapshot,
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
updateLatestAssistantMessage((message) => ({
|
||||
...message,
|
||||
events: snapshot,
|
||||
}));
|
||||
} else {
|
||||
const nextEvents = [...events];
|
||||
nextEvents[nextEvents.length - 1] = {
|
||||
|
|
@ -471,17 +467,10 @@ export function useAssistantChat({
|
|||
};
|
||||
eventsRef.current = nextEvents;
|
||||
const snapshot = [...nextEvents];
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last?.role === "assistant") {
|
||||
updated[updated.length - 1] = {
|
||||
...last,
|
||||
events: snapshot,
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
updateLatestAssistantMessage((message) => ({
|
||||
...message,
|
||||
events: snapshot,
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
@ -516,17 +505,10 @@ export function useAssistantChat({
|
|||
];
|
||||
}
|
||||
const snapshot = [...eventsRef.current];
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last?.role === "assistant") {
|
||||
updated[updated.length - 1] = {
|
||||
...last,
|
||||
events: snapshot,
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
updateLatestAssistantMessage((message) => ({
|
||||
...message,
|
||||
events: snapshot,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -543,17 +525,10 @@ export function useAssistantChat({
|
|||
];
|
||||
}
|
||||
const snapshot = [...eventsRef.current];
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last?.role === "assistant") {
|
||||
updated[updated.length - 1] = {
|
||||
...last,
|
||||
events: snapshot,
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
updateLatestAssistantMessage((message) => ({
|
||||
...message,
|
||||
events: snapshot,
|
||||
}));
|
||||
pushThinkingPlaceholder();
|
||||
continue;
|
||||
}
|
||||
|
|
@ -904,6 +879,85 @@ export function useAssistantChat({
|
|||
continue;
|
||||
}
|
||||
|
||||
if (data.type === "ask_inputs") {
|
||||
const rawItems = Array.isArray(data.items)
|
||||
? (data.items as unknown[])
|
||||
: [];
|
||||
const items = rawItems.reduce<Extract<
|
||||
AssistantEvent,
|
||||
{ type: "ask_inputs" }
|
||||
>["items"]>((acc, item, index) => {
|
||||
if (!item || typeof item !== "object") return acc;
|
||||
const row = item as Record<string, unknown>;
|
||||
const id =
|
||||
typeof row.id === "string" && row.id.trim()
|
||||
? row.id.trim()
|
||||
: `input-${index + 1}`;
|
||||
if (row.kind === "choice") {
|
||||
const options = Array.isArray(row.options)
|
||||
? (row.options as unknown[]).flatMap((option) => {
|
||||
if (!option || typeof option !== "object") return [];
|
||||
const optionRow = option as Record<string, unknown>;
|
||||
const value =
|
||||
typeof optionRow.value === "string"
|
||||
? optionRow.value
|
||||
: typeof optionRow.label === "string"
|
||||
? optionRow.label
|
||||
: "";
|
||||
if (!value.trim()) return [];
|
||||
return [
|
||||
{
|
||||
value,
|
||||
},
|
||||
];
|
||||
})
|
||||
: [];
|
||||
acc.push({
|
||||
id,
|
||||
kind: "choice" as const,
|
||||
question:
|
||||
typeof row.question === "string"
|
||||
? row.question
|
||||
: "Please choose an option.",
|
||||
options,
|
||||
allow_other: row.allow_other !== false,
|
||||
other_label:
|
||||
typeof row.other_label === "string"
|
||||
? row.other_label
|
||||
: "Other",
|
||||
response_prefix:
|
||||
typeof row.response_prefix === "string"
|
||||
? row.response_prefix
|
||||
: undefined,
|
||||
});
|
||||
return acc;
|
||||
}
|
||||
if (row.kind === "documents") {
|
||||
const documentTypes = Array.isArray(row.document_types)
|
||||
? (row.document_types as unknown[])
|
||||
.filter((type): type is string => typeof type === "string")
|
||||
.map((type) => type.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
acc.push({
|
||||
id,
|
||||
kind: "documents" as const,
|
||||
document_types: documentTypes,
|
||||
response_prefix:
|
||||
typeof row.response_prefix === "string"
|
||||
? row.response_prefix
|
||||
: undefined,
|
||||
});
|
||||
return acc;
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
if (items.length > 0) {
|
||||
pushEvent({ type: "ask_inputs", items });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.type === "doc_read") {
|
||||
updateMatchingEvent(
|
||||
(e) =>
|
||||
|
|
@ -1096,41 +1150,27 @@ export function useAssistantChat({
|
|||
? data.status
|
||||
: "final";
|
||||
const incoming = (data.citations ??
|
||||
[]) as CitationAnnotation[];
|
||||
[]) as Citation[];
|
||||
if (status === "started" || status === "partial") {
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last?.role === "assistant") {
|
||||
updated[updated.length - 1] = {
|
||||
...last,
|
||||
annotations: incoming,
|
||||
citationStatus: status,
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
updateLatestAssistantMessage((message) => ({
|
||||
...message,
|
||||
citations: incoming,
|
||||
citationStatus: status,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
// End-of-stream signal — scrub any lingering
|
||||
// placeholders so they don't persist into the
|
||||
// finalised message. First finalize content so adding
|
||||
// annotations cannot re-render the markdown/citation view
|
||||
// citations cannot re-render the markdown/citation view
|
||||
// against a streaming block.
|
||||
finalizeStreamingContent();
|
||||
clearStreamingPlaceholders();
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last?.role === "assistant") {
|
||||
updated[updated.length - 1] = {
|
||||
...last,
|
||||
annotations: incoming,
|
||||
citationStatus: incoming.length ? "final" : undefined,
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
updateLatestAssistantMessage((message) => ({
|
||||
...message,
|
||||
citations: incoming,
|
||||
citationStatus: incoming.length ? "final" : undefined,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -1166,7 +1206,7 @@ export function useAssistantChat({
|
|||
await loadChats();
|
||||
|
||||
const finalChatIdForTitle = streamedChatId || chatId || null;
|
||||
if (finalChatIdForTitle && newMessages.length === 1) {
|
||||
if (finalChatIdForTitle && apiMessagesForTurn.length === 1) {
|
||||
const titleParts = [message.content];
|
||||
if (message.workflow)
|
||||
titleParts.push(`Workflow: ${message.workflow.title}`);
|
||||
|
|
@ -1184,15 +1224,19 @@ export function useAssistantChat({
|
|||
finalizeStreamingReasoning();
|
||||
eventsRef.current = appendCancellationEvent(eventsRef.current);
|
||||
setMessages((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last?.role === "assistant") {
|
||||
const updated = [...prev];
|
||||
const assistantIndex = [...prev]
|
||||
.map((message, index) => ({ message, index }))
|
||||
.reverse()
|
||||
.find(({ message }) => message.role === "assistant")?.index;
|
||||
if (assistantIndex !== undefined) {
|
||||
const assistantMessage = prev[assistantIndex];
|
||||
const events = appendCancellationEvent(
|
||||
last.events ?? eventsRef.current,
|
||||
assistantMessage.events ?? eventsRef.current,
|
||||
);
|
||||
eventsRef.current = events;
|
||||
updated[updated.length - 1] = {
|
||||
...last,
|
||||
const updated = [...prev];
|
||||
updated[assistantIndex] = {
|
||||
...assistantMessage,
|
||||
events,
|
||||
};
|
||||
return updated;
|
||||
|
|
@ -1214,11 +1258,14 @@ export function useAssistantChat({
|
|||
? error.message
|
||||
: "Sorry, something went wrong.";
|
||||
setMessages((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last?.role === "assistant") {
|
||||
const assistantIndex = [...prev]
|
||||
.map((message, index) => ({ message, index }))
|
||||
.reverse()
|
||||
.find(({ message }) => message.role === "assistant")?.index;
|
||||
if (assistantIndex !== undefined) {
|
||||
const updated = [...prev];
|
||||
updated[updated.length - 1] = {
|
||||
...last,
|
||||
updated[assistantIndex] = {
|
||||
...updated[assistantIndex],
|
||||
error: errorMessage,
|
||||
};
|
||||
return updated;
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
export interface DocumentVersionRow {
|
||||
id: string;
|
||||
version_number: number | null;
|
||||
source:
|
||||
| "upload"
|
||||
| "assistant_edit"
|
||||
| "user_accept"
|
||||
| "user_reject"
|
||||
| "generated";
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DocumentVersionsResult {
|
||||
versions: DocumentVersionRow[];
|
||||
currentVersionId: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/** Refetch externally; used after accept/reject or new assistant edits. */
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the list of tracked versions for a document, with the numeric
|
||||
* version_number we use in UI labels ("V1", "V2", …).
|
||||
*/
|
||||
export function useDocumentVersions(
|
||||
documentId: string | null | undefined,
|
||||
refreshKey?: number,
|
||||
): DocumentVersionsResult {
|
||||
const [versions, setVersions] = useState<DocumentVersionRow[]>([]);
|
||||
const [currentVersionId, setCurrentVersionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!documentId) {
|
||||
setVersions([]);
|
||||
setCurrentVersionId(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
(async () => {
|
||||
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/${documentId}/versions`,
|
||||
{
|
||||
headers: token
|
||||
? { Authorization: `Bearer ${token}` }
|
||||
: {},
|
||||
},
|
||||
);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = (await resp.json()) as {
|
||||
versions: DocumentVersionRow[];
|
||||
current_version_id: string | null;
|
||||
};
|
||||
if (cancelled) return;
|
||||
setVersions(data.versions ?? []);
|
||||
setCurrentVersionId(data.current_version_id ?? null);
|
||||
} catch (e) {
|
||||
if (!cancelled)
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [documentId, refreshKey, tick]);
|
||||
|
||||
return {
|
||||
versions,
|
||||
currentVersionId,
|
||||
loading,
|
||||
error,
|
||||
refresh: () => setTick((t) => t + 1),
|
||||
};
|
||||
}
|
||||
|
|
@ -4,16 +4,25 @@ import { useEffect, useRef, useState } from "react";
|
|||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
/**
|
||||
* /display returns either PDF bytes (when the active version has a PDF
|
||||
* rendition) or raw DOCX bytes otherwise. Reporting the type lets the
|
||||
* caller swap between DocView (PDF.js) and DocxView (docx-preview)
|
||||
* accordingly.
|
||||
* /display returns PDF bytes (when the active version has a PDF rendition),
|
||||
* raw spreadsheet bytes (xlsx/xlsm/xls — never converted to PDF), or raw DOCX
|
||||
* bytes otherwise. Reporting the type lets the caller swap between PdfView
|
||||
* (PDF.js), SpreadsheetView (Fortune-sheet), and DocxView (docx-preview).
|
||||
*/
|
||||
export type DocResult =
|
||||
| { type: "pdf"; buffer: ArrayBuffer }
|
||||
| { type: "spreadsheet"; buffer: ArrayBuffer }
|
||||
| { type: "docx" }
|
||||
| null;
|
||||
|
||||
/** Office spreadsheet content types served raw by /display. */
|
||||
function isSpreadsheetContentType(contentType: string): boolean {
|
||||
return (
|
||||
contentType.includes("spreadsheetml") || // .xlsx
|
||||
contentType.includes("ms-excel") // .xls / .xlsm
|
||||
);
|
||||
}
|
||||
|
||||
export function useFetchSingleDoc(
|
||||
documentId: string | null | undefined,
|
||||
versionId?: string | null,
|
||||
|
|
@ -65,6 +74,9 @@ export function useFetchSingleDoc(
|
|||
if (contentType.includes("application/pdf")) {
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (!cancelled) setResult({ type: "pdf", buffer });
|
||||
} else if (isSpreadsheetContentType(contentType)) {
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (!cancelled) setResult({ type: "spreadsheet", buffer });
|
||||
} else {
|
||||
// Drain the body so the connection is reusable, but the
|
||||
// bytes are useless to the PDF viewer — the caller will
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
export const SUPPORTED_DOCUMENT_ACCEPT = ".pdf,.docx,.doc";
|
||||
export const SUPPORTED_DOCUMENT_ACCEPT =
|
||||
".pdf,.docx,.doc,.xlsx,.xlsm,.xls,.pptx,.ppt";
|
||||
export const UNSUPPORTED_DOCUMENT_WARNING_MESSAGE =
|
||||
"Unsupported file type. Only PDF, DOCX, and DOC files can be uploaded.";
|
||||
"Unsupported file type. Only PDF, Word, Excel, and PowerPoint files can be uploaded.";
|
||||
|
||||
const SUPPORTED_DOCUMENT_EXTENSIONS = new Set(["pdf", "docx", "doc"]);
|
||||
const SUPPORTED_DOCUMENT_EXTENSIONS = new Set([
|
||||
"pdf",
|
||||
"docx",
|
||||
"doc",
|
||||
"xlsx",
|
||||
"xlsm",
|
||||
"xls",
|
||||
"pptx",
|
||||
"ppt",
|
||||
]);
|
||||
|
||||
export function isSupportedDocumentFile(file: File): boolean {
|
||||
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||
|
|
|
|||
|
|
@ -8,12 +8,15 @@ import type {
|
|||
AssistantEvent,
|
||||
Chat,
|
||||
ChatDetailOut,
|
||||
CitationAnnotation,
|
||||
Citation,
|
||||
Document,
|
||||
Folder,
|
||||
Message,
|
||||
OpenSourceWorkflowContributorMode,
|
||||
OpenSourceWorkflowResponse,
|
||||
Project,
|
||||
Workflow,
|
||||
WorkflowContributor,
|
||||
TabularReview,
|
||||
TabularReviewDetailOut,
|
||||
} from "@/app/components/shared/types";
|
||||
|
|
@ -26,7 +29,7 @@ interface ServerMessage {
|
|||
content: string | AssistantEvent[] | null;
|
||||
files?: { filename: string; document_id?: string }[] | null;
|
||||
workflow?: { id: string; title: string } | null;
|
||||
annotations?: CitationAnnotation[] | null;
|
||||
citations?: Citation[] | null;
|
||||
created_at: string;
|
||||
}
|
||||
interface ServerChatDetailOut {
|
||||
|
|
@ -166,12 +169,13 @@ export async function listProjects(): Promise<Project[]> {
|
|||
export async function createProject(
|
||||
name: string,
|
||||
cm_number?: string,
|
||||
practice?: string,
|
||||
shared_with?: string[],
|
||||
): Promise<Project> {
|
||||
return apiRequest<Project>("/projects", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, cm_number, shared_with }),
|
||||
body: JSON.stringify({ name, cm_number, practice, shared_with }),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -226,10 +230,24 @@ export interface UserProfile {
|
|||
apiKeyStatus: ApiKeyStatus;
|
||||
}
|
||||
|
||||
export interface UserLookupResult {
|
||||
exists: boolean;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
}
|
||||
|
||||
export async function getUserProfile(): Promise<UserProfile> {
|
||||
return apiRequest<UserProfile>("/user/profile");
|
||||
}
|
||||
|
||||
export async function lookupUserByEmail(
|
||||
email: string,
|
||||
): Promise<UserLookupResult> {
|
||||
return apiRequest<UserLookupResult>(
|
||||
`/user/lookup?email=${encodeURIComponent(email)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateUserProfile(payload: {
|
||||
displayName?: string | null;
|
||||
organisation?: string | null;
|
||||
|
|
@ -411,6 +429,7 @@ export async function updateProject(
|
|||
payload: {
|
||||
name?: string;
|
||||
cm_number?: string;
|
||||
practice?: string | null;
|
||||
shared_with?: string[];
|
||||
},
|
||||
): Promise<Project> {
|
||||
|
|
@ -750,6 +769,7 @@ export async function getChat(chatId: string): Promise<ChatDetailOut> {
|
|||
const messages: Message[] = raw.messages.map((m) => {
|
||||
if (m.role === "user") {
|
||||
return {
|
||||
id: m.id,
|
||||
role: "user",
|
||||
content: typeof m.content === "string" ? m.content : "",
|
||||
files: m.files ?? undefined,
|
||||
|
|
@ -760,13 +780,14 @@ export async function getChat(chatId: string): Promise<ChatDetailOut> {
|
|||
? (m.content as AssistantEvent[])
|
||||
: undefined;
|
||||
return {
|
||||
id: m.id,
|
||||
role: "assistant",
|
||||
content:
|
||||
events
|
||||
?.filter((e) => e.type === "content")
|
||||
.map((e) => (e as { type: "content"; text: string }).text)
|
||||
.join("") ?? "",
|
||||
annotations: m.annotations ?? undefined,
|
||||
citations: m.citations ?? undefined,
|
||||
events,
|
||||
};
|
||||
});
|
||||
|
|
@ -832,6 +853,23 @@ export async function streamChat(payload: {
|
|||
chat_id?: string;
|
||||
project_id?: string;
|
||||
model?: string;
|
||||
ask_inputs_response?: {
|
||||
responses: (
|
||||
| {
|
||||
id: string;
|
||||
kind: "choice";
|
||||
question: string;
|
||||
answer?: string;
|
||||
skipped?: boolean;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "documents";
|
||||
filenames: string[];
|
||||
skipped?: boolean;
|
||||
}
|
||||
)[];
|
||||
};
|
||||
signal?: AbortSignal;
|
||||
}): Promise<Response> {
|
||||
const { signal, ...body } = payload;
|
||||
|
|
@ -862,6 +900,23 @@ export async function streamProjectChat(payload: {
|
|||
model?: string;
|
||||
displayed_doc?: { filename: string; document_id: string };
|
||||
attached_documents?: { filename: string; document_id: string }[];
|
||||
ask_inputs_response?: {
|
||||
responses: (
|
||||
| {
|
||||
id: string;
|
||||
kind: "choice";
|
||||
question: string;
|
||||
answer?: string;
|
||||
skipped?: boolean;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "documents";
|
||||
filenames: string[];
|
||||
skipped?: boolean;
|
||||
}
|
||||
)[];
|
||||
};
|
||||
signal?: AbortSignal;
|
||||
}): Promise<Response> {
|
||||
const { projectId, signal, ...body } = payload;
|
||||
|
|
@ -1138,7 +1193,9 @@ export async function createWorkflow(payload: {
|
|||
type: "assistant" | "tabular";
|
||||
prompt_md?: string;
|
||||
columns_config?: { index: number; name: string; prompt: string }[];
|
||||
language?: string | null;
|
||||
practice?: string | null;
|
||||
jurisdictions?: string[] | null;
|
||||
}): Promise<Workflow> {
|
||||
return apiRequest<Workflow>("/workflows", {
|
||||
method: "POST",
|
||||
|
|
@ -1153,7 +1210,9 @@ export async function updateWorkflow(
|
|||
title?: string;
|
||||
prompt_md?: string;
|
||||
columns_config?: { index: number; name: string; prompt: string }[];
|
||||
language?: string | null;
|
||||
practice?: string | null;
|
||||
jurisdictions?: string[] | null;
|
||||
},
|
||||
): Promise<Workflow> {
|
||||
return apiRequest<Workflow>(`/workflows/${workflowId}`, {
|
||||
|
|
@ -1167,6 +1226,23 @@ export async function deleteWorkflow(workflowId: string): Promise<void> {
|
|||
await apiRequest(`/workflows/${workflowId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function openSourceWorkflow(
|
||||
workflowId: string,
|
||||
payload: {
|
||||
contributor_mode: OpenSourceWorkflowContributorMode;
|
||||
contributor?: WorkflowContributor | null;
|
||||
},
|
||||
): Promise<OpenSourceWorkflowResponse> {
|
||||
return apiRequest<OpenSourceWorkflowResponse>(
|
||||
`/workflows/${workflowId}/open-source`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function listHiddenWorkflows(): Promise<string[]> {
|
||||
return apiRequest<string[]>("/workflows/hidden");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { supabase } from "@/lib/supabase";
|
|||
import {
|
||||
needsMfaVerification,
|
||||
VerificationCodeInput,
|
||||
} from "@/app/components/shared/MfaVerificationPopup";
|
||||
} from "@/app/components/popups/MfaVerificationPopup";
|
||||
import { markMfaVerifiedForGate } from "@/app/components/shared/MfaLoginGate";
|
||||
|
||||
type MfaFactor = {
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
50
frontend/src/components/ui/pill-button.tsx
Normal file
50
frontend/src/components/ui/pill-button.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PillButtonTone = "black" | "white" | "blue" | "danger";
|
||||
type PillButtonSize = "sm" | "normal";
|
||||
|
||||
type PillButtonProps = React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
tone: PillButtonTone;
|
||||
size?: PillButtonSize;
|
||||
};
|
||||
|
||||
const toneClasses: Record<PillButtonTone, string> = {
|
||||
black: "border-gray-700/40 bg-gray-950/88 text-white shadow-[0_3px_9px_rgba(15,23,42,0.10),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-1px_0_rgba(255,255,255,0.12),inset_0_-4px_9px_rgba(15,23,42,0.2)] backdrop-blur-xl hover:bg-gray-900/90 disabled:hover:bg-gray-950/88",
|
||||
white: "border-transparent bg-transparent text-gray-700 shadow-[0_1px_3px_rgba(0,0,0,0.22),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-1px_0_rgba(255,255,255,0.7)] hover:bg-gray-100 disabled:hover:bg-transparent",
|
||||
blue: "border-blue-500/35 bg-blue-600/90 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 hover:bg-blue-600 disabled:hover:bg-blue-600/90",
|
||||
danger: "border-red-700/35 bg-red-600/90 text-white shadow-[0_3px_9px_rgba(127,29,29,0.10),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-1px_0_rgba(255,255,255,0.14),inset_0_-4px_9px_rgba(127,29,29,0.18)] backdrop-blur-xl hover:bg-red-600 disabled:hover:bg-red-600/90",
|
||||
};
|
||||
|
||||
const sizeClasses: Record<PillButtonSize, string> = {
|
||||
sm: "px-2 py-1 text-xs",
|
||||
normal: "px-4 py-1.5 text-sm",
|
||||
};
|
||||
|
||||
export function PillButton({
|
||||
asChild = false,
|
||||
tone,
|
||||
size = "sm",
|
||||
type = "button",
|
||||
className,
|
||||
...props
|
||||
}: PillButtonProps) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
type={asChild ? undefined : type}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-1.5 rounded-full border font-medium transition-all active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100",
|
||||
toneClasses[tone],
|
||||
sizeClasses[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { ArrowUp, ArrowDown, X } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
|
||||
interface TextSearchWidgetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
currentMatchIdx: number;
|
||||
matchCount: number;
|
||||
setCurrentMatchIdx: (idx: number | ((prev: number) => number)) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TextSearchWidget({
|
||||
isOpen,
|
||||
onClose,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
currentMatchIdx,
|
||||
matchCount,
|
||||
setCurrentMatchIdx,
|
||||
className = "",
|
||||
}: TextSearchWidgetProps) {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleNext = () => {
|
||||
if (matchCount === 0) return;
|
||||
setCurrentMatchIdx((prev) => (prev + 1) % matchCount);
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (matchCount === 0) return;
|
||||
setCurrentMatchIdx((prev) => (prev - 1 + matchCount) % matchCount);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col bg-white shadow-lg border border-gray-200 rounded-md overflow-hidden min-w-[300px] ${className}`}
|
||||
>
|
||||
<div className="flex items-center gap-1 p-1">
|
||||
<div className="flex-1 relative">
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
autoFocus
|
||||
placeholder="Find"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="h-8 text-sm w-full pr-[80px] rounded-sm border-gray-200 bg-gray-100/50 focus-visible:ring-0 focus-visible:border-blue-600 placeholder:text-gray-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
onSearchChange("");
|
||||
} else if (e.key === "Enter") {
|
||||
if (e.shiftKey) handlePrev();
|
||||
else handleNext();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results count and navigation */}
|
||||
{searchQuery && (
|
||||
<div className="flex items-center justify-between px-2 pb-1 pt-0.5 text-xs text-gray-500">
|
||||
<span>
|
||||
{matchCount > 0
|
||||
? `${currentMatchIdx + 1} of ${matchCount}`
|
||||
: "No results"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
disabled={matchCount === 0}
|
||||
className="p-1 hover:bg-gray-100 rounded disabled:opacity-50"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={matchCount === 0}
|
||||
className="p-1 hover:bg-gray-100 rounded disabled:opacity-50"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* Extract and validate user from Supabase JWT token
|
||||
* Returns user info if valid, null if invalid or missing
|
||||
*
|
||||
* @param request NextRequest with Authorization header
|
||||
* @returns User object with email and id, or null
|
||||
*/
|
||||
export async function getUserFromRequest(request: NextRequest): Promise<{
|
||||
email: string;
|
||||
id: string;
|
||||
} | null> {
|
||||
try {
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate with Supabase
|
||||
const { createClient } = await import('@supabase/supabase-js');
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY!
|
||||
);
|
||||
|
||||
const { data: { user }, error } = await supabase.auth.getUser(token);
|
||||
|
||||
if (error || !user) {
|
||||
console.warn('[Auth] Invalid or expired token:', error?.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
console.warn('[Auth] User has no email');
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
email: user.email,
|
||||
id: user.id
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Auth] Error validating token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
import mammoth from "mammoth";
|
||||
|
||||
// Helper function to convert File to base64 data URL
|
||||
const fileToBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
resolve(result);
|
||||
};
|
||||
reader.onerror = (error) => reject(error);
|
||||
});
|
||||
};
|
||||
|
||||
export async function processFiles(
|
||||
files: { name: string; file?: File }[]
|
||||
): Promise<{ filename: string; data: string }[]> {
|
||||
const processedFilesPromises = files
|
||||
.filter((f) => f.file)
|
||||
.map(async (f) => {
|
||||
const file = f.file!;
|
||||
let processedFile: File = file;
|
||||
|
||||
// Apply conversions if needed
|
||||
if (
|
||||
file.type ===
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
|
||||
file.name.endsWith(".docx")
|
||||
) {
|
||||
processedFile = await convertDocxToText(file);
|
||||
} else if (
|
||||
file.type === "text/plain" ||
|
||||
file.name.endsWith(".txt") ||
|
||||
file.name.endsWith(".doc")
|
||||
) {
|
||||
processedFile = await convertToTextFile(file);
|
||||
}
|
||||
// PDFs and others are left as-is
|
||||
|
||||
return {
|
||||
filename: processedFile.name,
|
||||
data: await fileToBase64(processedFile),
|
||||
};
|
||||
});
|
||||
|
||||
return Promise.all(processedFilesPromises);
|
||||
}
|
||||
|
||||
async function convertDocxToText(file: File): Promise<File> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const result = await mammoth.extractRawText({ arrayBuffer });
|
||||
const text = result.value;
|
||||
return createTextFile(text, file.name);
|
||||
}
|
||||
|
||||
async function convertToTextFile(file: File): Promise<File> {
|
||||
const text = await file.text();
|
||||
return createTextFile(text, file.name);
|
||||
}
|
||||
|
||||
function createTextFile(content: string, fileName: string): File {
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
return new File([blob], fileName, { type: "text/plain" });
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
/**
|
||||
* Compute a display label from node components.
|
||||
* This mirrors the backend format_lineage_label function.
|
||||
*/
|
||||
|
||||
const STRUCTURE_NODE_TYPES = [
|
||||
"subtitle",
|
||||
"division",
|
||||
"chapter",
|
||||
"subchapter",
|
||||
"part",
|
||||
"subpart",
|
||||
"article",
|
||||
"subarticle",
|
||||
"appendix",
|
||||
] as const;
|
||||
|
||||
type StructureNodeType = (typeof STRUCTURE_NODE_TYPES)[number];
|
||||
type CodeType = "title" | "section" | StructureNodeType | string;
|
||||
|
||||
export interface NodeComponents {
|
||||
code_type: CodeType;
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
export function computeLabel({ code_type, identifier }: NodeComponents): string {
|
||||
return `${code_type} ${identifier}`;
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
/** Encode a tree slug containing `/` so it can live inside a Next.js dynamic segment. */
|
||||
export function encodeNodeSlug(slug: string): string {
|
||||
if (!slug) {
|
||||
return "";
|
||||
}
|
||||
return slug.replace(/\//g, "~");
|
||||
}
|
||||
|
||||
/** Decode the slug from the route segment back into its canonical form. */
|
||||
export function decodeNodeSlug(segment: string): string {
|
||||
return segment.replace(/~/g, "/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a backend slug to a frontend URL path.
|
||||
* Backend slugs match frontend URLs exactly now.
|
||||
* Examples:
|
||||
* "usc/title-49" -> "/usc/title-49"
|
||||
* "usc/title-49/subtitle-IV/part-B/chapter-145" -> "/usc/title-49/subtitle-IV/part-B/chapter-145"
|
||||
* "usc/title-49/14501" -> "/usc/title-49/14501"
|
||||
*/
|
||||
const UNICODE_DASH_REGEX =
|
||||
/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212\uFE58\uFE63\uFF0D]/g;
|
||||
|
||||
function normalizeSlugDashes(value: string): string {
|
||||
return value.replace(UNICODE_DASH_REGEX, "-");
|
||||
}
|
||||
|
||||
export function slugToPath(slug: string): string {
|
||||
if (!slug) {
|
||||
return "";
|
||||
}
|
||||
const normalized = normalizeSlugDashes(slug);
|
||||
|
||||
// Handle CFR slugs: "cfr/title-1/..." -> "/sources/cfr/title-1/..."
|
||||
if (normalized.startsWith("cfr/")) {
|
||||
const cleanSlug = normalized.replace(/^cfr\//, "");
|
||||
return `/sources/cfr/${cleanSlug}`;
|
||||
}
|
||||
|
||||
// Handle USC slugs: "usc/title-05" -> "/sources/usc/title-05"
|
||||
const cleanSlug = normalized.replace(/^usc\//, "");
|
||||
return `/sources/usc/${cleanSlug}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a section URL from title and section numbers.
|
||||
* Format: "/sources/usc/title-{number}/{section_number}"
|
||||
*/
|
||||
export function buildSectionUrl(
|
||||
titleNumber: number,
|
||||
sectionNumber: string,
|
||||
): string {
|
||||
const paddedTitle = titleNumber.toString().padStart(2, "0");
|
||||
const normalizedSection = normalizeSlugDashes(sectionNumber);
|
||||
return `/sources/usc/title-${paddedTitle}/${normalizedSection}`;
|
||||
}
|
||||
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