mirror of
https://github.com/willchen96/mike.git
synced 2026-06-16 21:05:12 +02:00
Add courtlistener intergration, liquid glass redesign, UI improvements, version control, various fixes
This commit is contained in:
parent
d39f5806e5
commit
44e868eb42
106 changed files with 16350 additions and 7753 deletions
222
frontend/src/app/(pages)/account/api-keys/page.tsx
Normal file
222
frontend/src/app/(pages)/account/api-keys/page.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, Eye, EyeOff, Save, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
|
||||
const MODEL_API_KEY_FIELDS = [
|
||||
{
|
||||
provider: "claude",
|
||||
label: "Anthropic (Claude) API Key",
|
||||
placeholder: "sk-ant-...",
|
||||
},
|
||||
{
|
||||
provider: "gemini",
|
||||
label: "Google (Gemini) API Key",
|
||||
placeholder: "AI...",
|
||||
},
|
||||
{
|
||||
provider: "openai",
|
||||
label: "OpenAI API Key",
|
||||
placeholder: "sk-...",
|
||||
},
|
||||
{
|
||||
provider: "openrouter",
|
||||
label: "OpenRouter API Key",
|
||||
placeholder: "sk-or-...",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const OTHER_API_KEY_FIELDS = [
|
||||
{
|
||||
provider: "courtlistener",
|
||||
label: "CourtListener API Key",
|
||||
placeholder: "Token...",
|
||||
description:
|
||||
"Add a CourtListener API key if you want the latest CourtListener data. Otherwise, Mike will use the bulk data hosted by us.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
const { profile, updateApiKey } = useUserProfile();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-3 text-2xl font-medium font-serif text-gray-900">
|
||||
API Keys
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
You must provide your own API keys for the app to work or add
|
||||
your API keys into the .env file if you are running your own
|
||||
instance of Mike. All API keys are encrypted in storage.
|
||||
</p>
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white divide-y divide-gray-200">
|
||||
{MODEL_API_KEY_FIELDS.map((field) => (
|
||||
<ApiKeyField
|
||||
key={field.provider}
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
hasSavedKey={
|
||||
!!profile?.apiKeys[field.provider].configured
|
||||
}
|
||||
isServerConfigured={
|
||||
profile?.apiKeys[field.provider].source === "env"
|
||||
}
|
||||
onSave={(value) =>
|
||||
updateApiKey(field.provider, value.trim() || null)
|
||||
}
|
||||
onRemove={() => updateApiKey(field.provider, null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 overflow-hidden rounded-xl border border-gray-200 bg-white divide-y divide-gray-200">
|
||||
{OTHER_API_KEY_FIELDS.map((field) => (
|
||||
<ApiKeyField
|
||||
key={field.provider}
|
||||
label={field.label}
|
||||
description={field.description}
|
||||
placeholder={field.placeholder}
|
||||
hasSavedKey={
|
||||
!!profile?.apiKeys[field.provider].configured
|
||||
}
|
||||
isServerConfigured={
|
||||
profile?.apiKeys[field.provider].source === "env"
|
||||
}
|
||||
onSave={(value) =>
|
||||
updateApiKey(field.provider, value.trim() || null)
|
||||
}
|
||||
onRemove={() => updateApiKey(field.provider, null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyField({
|
||||
label,
|
||||
description,
|
||||
placeholder,
|
||||
hasSavedKey,
|
||||
isServerConfigured,
|
||||
onSave,
|
||||
onRemove,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
placeholder: string;
|
||||
hasSavedKey: boolean;
|
||||
isServerConfigured: boolean;
|
||||
onSave: (value: string) => Promise<boolean>;
|
||||
onRemove: () => Promise<boolean>;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const [reveal, setReveal] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setValue("");
|
||||
}, [hasSavedKey]);
|
||||
|
||||
const dirty = value.trim().length > 0;
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
const ok = await onSave(value);
|
||||
setIsSaving(false);
|
||||
if (ok) {
|
||||
setValue("");
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} else {
|
||||
alert(`Failed to save ${label}.`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
setIsSaving(true);
|
||||
const ok = await onRemove();
|
||||
setIsSaving(false);
|
||||
if (!ok) alert(`Failed to remove ${label}.`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 py-5">
|
||||
<label className="text-sm font-medium text-gray-700 block mb-2">
|
||||
{label}
|
||||
</label>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-500 mb-3">{description}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={reveal ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={
|
||||
isServerConfigured
|
||||
? "Server .env key configured"
|
||||
: hasSavedKey
|
||||
? "Saved key hidden"
|
||||
: placeholder
|
||||
}
|
||||
className="bg-gray-50 pr-10 shadow-none disabled:text-gray-700 disabled:placeholder:text-gray-700"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={isServerConfigured}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReveal((r) => !r)}
|
||||
disabled={isServerConfigured}
|
||||
className="absolute inset-y-0 right-2 flex items-center text-gray-400 hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label={reveal ? "Hide key" : "Show key"}
|
||||
>
|
||||
{reveal ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="outline"
|
||||
disabled={isServerConfigured || isSaving || !dirty || saved}
|
||||
className="h-9 min-w-[74px] gap-1.5 bg-white px-2.5 text-xs text-gray-700 shadow-none hover:bg-gray-50"
|
||||
>
|
||||
{isSaving ? (
|
||||
"Saving..."
|
||||
) : saved ? (
|
||||
<>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Saved
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{hasSavedKey && !isServerConfigured && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleRemove}
|
||||
disabled={isSaving}
|
||||
className="h-9 gap-1.5 bg-white px-2.5 text-xs text-red-600 shadow-none hover:bg-red-50 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,7 +13,8 @@ interface TabDef {
|
|||
|
||||
const TABS: TabDef[] = [
|
||||
{ id: "general", label: "General", href: "/account" },
|
||||
{ id: "models", label: "Models & API Keys", href: "/account/models" },
|
||||
{ id: "models", label: "Model Preferences", href: "/account/models" },
|
||||
{ id: "api-keys", label: "API Keys", href: "/account/api-keys" },
|
||||
];
|
||||
|
||||
export default function AccountLayout({
|
||||
|
|
@ -33,7 +34,7 @@ export default function AccountLayout({
|
|||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="h-dvh bg-white flex items-center justify-center">
|
||||
<div className="h-dvh flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AlertCircle, Check, ChevronDown, Eye, EyeOff } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AlertCircle, Check, ChevronDown, Loader2 } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -14,123 +12,133 @@ import {
|
|||
} from "@/components/ui/dropdown-menu";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import type { ApiKeyState } from "@/app/lib/mikeApi";
|
||||
import { MODELS } from "@/app/components/assistant/ModelToggle";
|
||||
import {
|
||||
MODELS,
|
||||
SETTINGS_MODELS,
|
||||
type ModelOption,
|
||||
} from "@/app/components/assistant/ModelToggle";
|
||||
import {
|
||||
isModelAvailable,
|
||||
modelGroupToProvider,
|
||||
providerLabel,
|
||||
} from "@/app/lib/modelAvailability";
|
||||
|
||||
const API_KEY_FIELDS = [
|
||||
{
|
||||
provider: "claude",
|
||||
label: "Anthropic (Claude) API Key",
|
||||
placeholder: "sk-ant-…",
|
||||
},
|
||||
{
|
||||
provider: "gemini",
|
||||
label: "Google (Gemini) API Key",
|
||||
placeholder: "AI…",
|
||||
},
|
||||
{
|
||||
provider: "openai",
|
||||
label: "OpenAI API Key",
|
||||
placeholder: "sk-…",
|
||||
},
|
||||
] as const;
|
||||
type ModelPreferenceField = "titleModel" | "tabularModel";
|
||||
|
||||
export default function ModelsAndApiKeysPage() {
|
||||
const { profile, updateModelPreference, updateApiKey } = useUserProfile();
|
||||
export default function ModelPreferencesPage() {
|
||||
const { profile, updateModelPreference } = useUserProfile();
|
||||
const [savingField, setSavingField] = useState<ModelPreferenceField | null>(
|
||||
null,
|
||||
);
|
||||
const [savedField, setSavedField] = useState<ModelPreferenceField | null>(
|
||||
null,
|
||||
);
|
||||
const [optimisticValues, setOptimisticValues] = useState<
|
||||
Partial<Record<ModelPreferenceField, string>>
|
||||
>({});
|
||||
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleModelChange = async (
|
||||
field: ModelPreferenceField,
|
||||
id: string,
|
||||
) => {
|
||||
setOptimisticValues((current) => ({ ...current, [field]: id }));
|
||||
setSavedField(null);
|
||||
setSavingField(field);
|
||||
const ok = await updateModelPreference(field, id);
|
||||
setSavingField((current) => (current === field ? null : current));
|
||||
if (ok) {
|
||||
setSavedField(field);
|
||||
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
|
||||
savedTimerRef.current = setTimeout(() => {
|
||||
setSavedField((current) => (current === field ? null : current));
|
||||
}, 1600);
|
||||
} else {
|
||||
setOptimisticValues((current) => {
|
||||
const next = { ...current };
|
||||
delete next[field];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Model Preferences */}
|
||||
<div className="pb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h2 className="text-2xl font-medium font-serif">
|
||||
Model Preferences
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-4 max-w-md">
|
||||
<div>
|
||||
<label className="text-sm text-gray-600 block mb-2">
|
||||
Tabular review model
|
||||
</label>
|
||||
<p className="text-xs text-gray-400 mb-2">
|
||||
We recommend using a smaller model for tabular
|
||||
reviews to reduce token costs.
|
||||
</p>
|
||||
<TabularModelDropdown
|
||||
value={
|
||||
profile?.tabularModel ??
|
||||
"gemini-3-flash-preview"
|
||||
}
|
||||
apiKeys={profile?.apiKeys}
|
||||
onChange={(id) =>
|
||||
updateModelPreference("tabularModel", id)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h2 className="text-2xl font-medium font-serif">
|
||||
Model Preferences
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* API Keys */}
|
||||
<div className="py-6">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h2 className="text-2xl font-medium font-serif">
|
||||
API Keys
|
||||
</h2>
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white divide-y divide-gray-200">
|
||||
<div className="px-4 py-5">
|
||||
<label className="text-sm font-medium text-gray-700 block mb-2">
|
||||
Title generation model
|
||||
</label>
|
||||
<p className="text-xs text-gray-400 mb-2">
|
||||
Used for naming chats and other lightweight titles.
|
||||
</p>
|
||||
<ModelPreferenceDropdown
|
||||
value={
|
||||
optimisticValues.titleModel ??
|
||||
profile?.titleModel ??
|
||||
"gemini-3.1-flash-lite-preview"
|
||||
}
|
||||
options={SETTINGS_MODELS}
|
||||
apiKeys={profile?.apiKeys}
|
||||
isSaving={savingField === "titleModel"}
|
||||
isSaved={savedField === "titleModel"}
|
||||
onChange={(id) => handleModelChange("titleModel", id)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-4 max-w-xl">
|
||||
You must provide your own API keys for the app to work or
|
||||
add your API keys into the .env file if you are running your
|
||||
own instance of Mike.
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mb-4 max-w-xl">
|
||||
Title generation automatically routes to the cheapest
|
||||
configured provider model.
|
||||
</p>
|
||||
<div className="space-y-4 max-w-xl">
|
||||
{API_KEY_FIELDS.map((field) => (
|
||||
<ApiKeyField
|
||||
key={field.provider}
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
hasSavedKey={
|
||||
!!profile?.apiKeys[field.provider].configured
|
||||
}
|
||||
isServerConfigured={
|
||||
profile?.apiKeys[field.provider].source ===
|
||||
"env"
|
||||
}
|
||||
onSave={(value) =>
|
||||
updateApiKey(
|
||||
field.provider,
|
||||
value.trim() || null,
|
||||
)
|
||||
}
|
||||
onRemove={() =>
|
||||
updateApiKey(field.provider, null)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<div className="px-4 py-5">
|
||||
<label className="text-sm font-medium text-gray-700 block mb-2">
|
||||
Tabular review model
|
||||
</label>
|
||||
<p className="text-xs text-gray-400 mb-2">
|
||||
We recommend using a smaller model for tabular reviews
|
||||
to reduce token costs.
|
||||
</p>
|
||||
<ModelPreferenceDropdown
|
||||
value={
|
||||
optimisticValues.tabularModel ??
|
||||
profile?.tabularModel ??
|
||||
"gemini-3-flash-preview"
|
||||
}
|
||||
options={MODELS}
|
||||
apiKeys={profile?.apiKeys}
|
||||
isSaving={savingField === "tabularModel"}
|
||||
isSaved={savedField === "tabularModel"}
|
||||
onChange={(id) => handleModelChange("tabularModel", id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabularModelDropdown({
|
||||
function ModelPreferenceDropdown({
|
||||
value,
|
||||
onChange,
|
||||
apiKeys,
|
||||
options,
|
||||
isSaving,
|
||||
isSaved,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
apiKeys?: ApiKeyState;
|
||||
options: ModelOption[];
|
||||
isSaving?: boolean;
|
||||
isSaved?: boolean;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const selected = MODELS.find((m) => m.id === value);
|
||||
const selected = options.find((m) => m.id === value);
|
||||
const selectedAvailable = apiKeys ? isModelAvailable(value, apiKeys) : true;
|
||||
const groups: ("Anthropic" | "Google" | "OpenAI")[] = [
|
||||
"Anthropic",
|
||||
|
|
@ -143,7 +151,8 @@ function TabularModelDropdown({
|
|||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full h-9 rounded-md border border-gray-300 bg-white px-3 text-sm shadow-sm flex items-center justify-between gap-2 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-black/10"
|
||||
disabled={isSaving}
|
||||
className="w-full h-9 rounded-md border border-gray-300 bg-gray-50 px-3 text-sm flex items-center justify-between gap-2 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-black/10"
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
{!selectedAvailable && (
|
||||
|
|
@ -153,9 +162,15 @@ function TabularModelDropdown({
|
|||
{selected?.label ?? "Select a model"}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 shrink-0 text-gray-500 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-gray-500" />
|
||||
) : isSaved ? (
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-green-600" />
|
||||
) : (
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 shrink-0 text-gray-500 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
|
|
@ -164,7 +179,7 @@ function TabularModelDropdown({
|
|||
align="start"
|
||||
>
|
||||
{groups.map((group, gi) => {
|
||||
const items = MODELS.filter((m) => m.group === group);
|
||||
const items = options.filter((m) => m.group === group);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div key={group}>
|
||||
|
|
@ -209,133 +224,3 @@ function TabularModelDropdown({
|
|||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyField({
|
||||
label,
|
||||
placeholder,
|
||||
hasSavedKey,
|
||||
isServerConfigured,
|
||||
onSave,
|
||||
onRemove,
|
||||
}: {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
hasSavedKey: boolean;
|
||||
isServerConfigured: boolean;
|
||||
onSave: (value: string) => Promise<boolean>;
|
||||
onRemove: () => Promise<boolean>;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const [reveal, setReveal] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setValue("");
|
||||
}, [hasSavedKey]);
|
||||
|
||||
const dirty = value.trim().length > 0;
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
const ok = await onSave(value);
|
||||
setIsSaving(false);
|
||||
if (ok) {
|
||||
setValue("");
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} else {
|
||||
alert(`Failed to save ${label}.`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
setIsSaving(true);
|
||||
const ok = await onRemove();
|
||||
setIsSaving(false);
|
||||
if (!ok) alert(`Failed to remove ${label}.`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="text-sm text-gray-600 block mb-2">{label}</label>
|
||||
{isServerConfigured && (
|
||||
<div className="mb-2 rounded-md border border-blue-100 bg-blue-50 px-3 py-2">
|
||||
<p className="text-xs text-blue-800">
|
||||
A server .env key is configured for this provider.
|
||||
Browser API-key edits are disabled.
|
||||
</p>
|
||||
{hasSavedKey && (
|
||||
<p className="mt-1 text-xs text-blue-800">
|
||||
The server key will be used for this provider.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hasSavedKey && !isServerConfigured && (
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
A key is saved. Paste a new key to replace it.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={reveal ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={
|
||||
isServerConfigured
|
||||
? "Server .env key configured"
|
||||
: hasSavedKey
|
||||
? "Saved key hidden"
|
||||
: placeholder
|
||||
}
|
||||
className="pr-10"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={isServerConfigured}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReveal((r) => !r)}
|
||||
disabled={isServerConfigured}
|
||||
className="absolute inset-y-0 right-2 flex items-center text-gray-400 hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label={reveal ? "Hide key" : "Show key"}
|
||||
>
|
||||
{reveal ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isServerConfigured || isSaving || !dirty || saved}
|
||||
className="min-w-[80px] transition-all bg-black hover:bg-gray-900 text-white"
|
||||
>
|
||||
{isSaving ? (
|
||||
"Saving..."
|
||||
) : saved ? (
|
||||
<>
|
||||
<Check className="h-4 w-3" />
|
||||
Saved
|
||||
</>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
{hasSavedKey && !isServerConfigured && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleRemove}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
|
|||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LogOut, Check } from "lucide-react";
|
||||
import { LogOut, Check, Save } from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import { deleteAccount } from "@/app/lib/mikeApi";
|
||||
|
|
@ -78,163 +78,188 @@ export default function AccountPage() {
|
|||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-8">
|
||||
{/* Profile Settings */}
|
||||
<div className="pb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h2 className="text-2xl font-medium font-serif">Profile</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-gray-600 block mb-2">
|
||||
Display Name
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-2xl font-medium font-serif text-gray-900">
|
||||
Profile
|
||||
</h2>
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-gray-600 block mb-2">
|
||||
Display Name
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) =>
|
||||
setDisplayName(e.target.value)
|
||||
}
|
||||
placeholder="Enter your name"
|
||||
className="flex-1 bg-gray-50 shadow-none"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSaveDisplayName}
|
||||
variant="outline"
|
||||
disabled={
|
||||
isSavingName ||
|
||||
!displayName.trim() ||
|
||||
saved
|
||||
}
|
||||
className="h-9 min-w-[74px] gap-1.5 bg-white px-2.5 text-xs text-gray-700 shadow-none hover:bg-gray-50"
|
||||
>
|
||||
{isSavingName ? (
|
||||
"Saving..."
|
||||
) : saved ? (
|
||||
<>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Saved
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-600 block mb-2">
|
||||
Organisation
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={organisation}
|
||||
onChange={(e) =>
|
||||
setOrganisation(e.target.value)
|
||||
}
|
||||
placeholder="Enter your organisation"
|
||||
className="flex-1 bg-gray-50 shadow-none"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSaveOrganisation}
|
||||
variant="outline"
|
||||
disabled={
|
||||
isSavingOrg ||
|
||||
organisation.trim() ===
|
||||
(profile?.organisation ?? "") ||
|
||||
orgSaved
|
||||
}
|
||||
className="h-9 min-w-[74px] gap-1.5 bg-white px-2.5 text-xs text-gray-700 shadow-none hover:bg-gray-50"
|
||||
>
|
||||
{isSavingOrg ? (
|
||||
"Saving..."
|
||||
) : orgSaved ? (
|
||||
<>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Saved
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-600 block mb-2">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Enter your name"
|
||||
className="flex-1"
|
||||
type="email"
|
||||
value={user?.email ?? ""}
|
||||
disabled
|
||||
className="bg-gray-50 shadow-none disabled:text-gray-700 disabled:opacity-100"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSaveDisplayName}
|
||||
disabled={
|
||||
isSavingName || !displayName.trim() || saved
|
||||
}
|
||||
className="min-w-[80px] transition-all bg-black hover:bg-gray-900 text-white"
|
||||
>
|
||||
{isSavingName ? (
|
||||
"Saving..."
|
||||
) : saved ? (
|
||||
<>
|
||||
<Check className="h-4 w-3" />
|
||||
Saved
|
||||
</>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-600 block mb-2">
|
||||
Organisation
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={organisation}
|
||||
onChange={(e) =>
|
||||
setOrganisation(e.target.value)
|
||||
}
|
||||
placeholder="Enter your organisation"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSaveOrganisation}
|
||||
disabled={
|
||||
isSavingOrg ||
|
||||
organisation.trim() ===
|
||||
(profile?.organisation ?? "") ||
|
||||
orgSaved
|
||||
}
|
||||
className="min-w-[80px] transition-all bg-black hover:bg-gray-900 text-white"
|
||||
>
|
||||
{isSavingOrg ? (
|
||||
"Saving..."
|
||||
) : orgSaved ? (
|
||||
<>
|
||||
<Check className="h-4 w-3" />
|
||||
Saved
|
||||
</>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-600 block mb-2">
|
||||
Email
|
||||
</label>
|
||||
<p className="text-base">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Plan */}
|
||||
<div className="py-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h2 className="text-2xl font-medium font-serif">
|
||||
Usage Plan
|
||||
</h2>
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-2xl font-medium font-serif text-gray-900">
|
||||
Usage Plan
|
||||
</h2>
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white p-4">
|
||||
<div>
|
||||
<p className="text-base font-medium text-gray-500 capitalize">
|
||||
{profile?.tier || "Free"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-base font-medium text-gray-500 capitalize">
|
||||
{profile?.tier || "Free"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="py-6">
|
||||
<h2 className="text-2xl font-medium font-serif mb-4">
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-2xl font-medium font-serif text-gray-900">
|
||||
Actions
|
||||
</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleLogout}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="py-6">
|
||||
<h2 className="text-2xl font-medium font-serif mb-1 text-red-600">
|
||||
Danger Zone
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Permanently delete your account and all associated data.
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
{deleteConfirm ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 space-y-3 max-w-sm">
|
||||
<p className="text-sm font-medium text-red-700">
|
||||
Are you sure? This will permanently delete your
|
||||
account.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteConfirm(false)}
|
||||
disabled={isDeleting}
|
||||
className="text-sm"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={isDeleting}
|
||||
className="text-sm bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
{isDeleting ? "Deleting…" : "Delete Account"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white p-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteConfirm(true)}
|
||||
className="w-full sm:w-auto border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
onClick={handleLogout}
|
||||
className="w-full shadow-none sm:w-auto"
|
||||
>
|
||||
Delete Account
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Sign Out
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-2xl font-medium font-serif text-red-600">
|
||||
Danger Zone
|
||||
</h2>
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white p-4">
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Permanently delete your account and all associated data.
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
{deleteConfirm ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 space-y-3 max-w-sm">
|
||||
<p className="text-sm font-medium text-red-700">
|
||||
Are you sure? This will permanently delete your
|
||||
account.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteConfirm(false)}
|
||||
disabled={isDeleting}
|
||||
className="text-sm shadow-none"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={isDeleting}
|
||||
className="bg-red-600 text-sm text-white shadow-none hover:bg-red-700"
|
||||
>
|
||||
{isDeleting
|
||||
? "Deleting…"
|
||||
: "Delete Account"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteConfirm(true)}
|
||||
className="w-full border-red-200 text-red-600 shadow-none hover:bg-red-50 hover:text-red-700 sm:w-auto"
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export default function AssistantChatPage() {
|
|||
|
||||
return (
|
||||
<ChatView
|
||||
chatId={id}
|
||||
messages={messages}
|
||||
isResponseLoading={isResponseLoading}
|
||||
handleChat={handleChat}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,20 @@ import { useRouter } from "next/navigation";
|
|||
import { useAssistantChat } from "@/app/hooks/useAssistantChat";
|
||||
import { InitialView } from "@/app/components/assistant/InitialView";
|
||||
import { ChatView } from "@/app/components/assistant/ChatView";
|
||||
import type { MikeMessage } from "@/app/components/shared/types";
|
||||
import type { Message } from "@/app/components/shared/types";
|
||||
|
||||
export default function AssistantPage() {
|
||||
const router = useRouter();
|
||||
const { messages, isResponseLoading, handleChat, handleNewChat, cancel } =
|
||||
useAssistantChat();
|
||||
const {
|
||||
messages,
|
||||
isResponseLoading,
|
||||
handleChat,
|
||||
handleNewChat,
|
||||
cancel,
|
||||
chatId,
|
||||
} = useAssistantChat();
|
||||
|
||||
async function handleInitialSubmit(message: MikeMessage) {
|
||||
async function handleInitialSubmit(message: Message) {
|
||||
const chatId = await handleNewChat(message);
|
||||
if (chatId) router.push(`/assistant/chat/${chatId}`);
|
||||
}
|
||||
|
|
@ -26,6 +32,7 @@ export default function AssistantPage() {
|
|||
|
||||
return (
|
||||
<ChatView
|
||||
chatId={chatId}
|
||||
messages={messages}
|
||||
isResponseLoading={isResponseLoading}
|
||||
handleChat={handleChat}
|
||||
|
|
|
|||
|
|
@ -79,13 +79,20 @@ export default function MikeLayout({
|
|||
<SidebarContext.Provider
|
||||
value={{
|
||||
setSidebarOpen: (open) => {
|
||||
const isSmall =
|
||||
typeof window !== "undefined" &&
|
||||
window.innerWidth < 768;
|
||||
if (isSmall) {
|
||||
if (!open) setIsSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
setIsSidebarOpen(open);
|
||||
setIsSidebarOpenDesktop(open);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="h-dvh bg-white flex flex-col">
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<div className="h-dvh flex flex-col bg-gray-50/80">
|
||||
<div className="flex-1 flex min-w-0 overflow-visible">
|
||||
<AppSidebar
|
||||
isOpen={isSidebarOpen}
|
||||
onToggle={handleSidebarToggle}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@ import {
|
|||
ChevronRight,
|
||||
FileText,
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
|
@ -46,13 +44,14 @@ import { MikeIcon } from "@/components/chat/mike-icon";
|
|||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||
import { useSidebar } from "@/app/contexts/SidebarContext";
|
||||
import { PageHeader } from "@/app/components/shared/PageHeader";
|
||||
import type {
|
||||
CitationQuote,
|
||||
MikeCitationAnnotation,
|
||||
MikeDocument,
|
||||
MikeEditAnnotation,
|
||||
MikeMessage,
|
||||
MikeProject,
|
||||
CitationAnnotation,
|
||||
Document,
|
||||
EditAnnotation,
|
||||
Message,
|
||||
Project,
|
||||
} from "@/app/components/shared/types";
|
||||
import { expandCitationToEntries } from "@/app/components/shared/types";
|
||||
|
||||
|
|
@ -206,7 +205,7 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
const username =
|
||||
profile?.displayName?.trim() || user?.email?.split("@")[0] || "there";
|
||||
|
||||
const [project, setProject] = useState<MikeProject | null>(null);
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [chatTitle, setChatTitle] = useState<string | null>(null);
|
||||
const [chatOwnerId, setChatOwnerId] = useState<string | null>(null);
|
||||
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);
|
||||
|
|
@ -254,7 +253,7 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
chats,
|
||||
saveChat,
|
||||
} = useChatHistoryContext();
|
||||
const [initialMessages] = useState<MikeMessage[]>(newChatMessages ?? []);
|
||||
const [initialMessages] = useState<Message[]>(newChatMessages ?? []);
|
||||
const { messages, isResponseLoading, handleChat, setMessages, cancel } =
|
||||
useAssistantChat({ initialMessages, chatId, projectId });
|
||||
|
||||
|
|
@ -470,7 +469,7 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
const handleSubmit = useCallback(
|
||||
(message: MikeMessage) => {
|
||||
(message: Message) => {
|
||||
if (!activeTab) return handleChat(message);
|
||||
return handleChat(message, {
|
||||
displayedDoc: {
|
||||
|
|
@ -482,11 +481,12 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
[activeTab, handleChat],
|
||||
);
|
||||
|
||||
const handleDocClick = (doc: MikeDocument) => {
|
||||
const handleDocClick = (doc: Document) => {
|
||||
openTab(doc.id, doc.filename);
|
||||
};
|
||||
|
||||
const handleCitationClick = (citation: MikeCitationAnnotation) => {
|
||||
const handleCitationClick = (citation: CitationAnnotation) => {
|
||||
if (citation.kind === "case") return;
|
||||
openTab(
|
||||
citation.document_id,
|
||||
citation.filename,
|
||||
|
|
@ -503,7 +503,7 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
openTab(args.documentId, args.filename, undefined, args.versionId);
|
||||
};
|
||||
|
||||
const handleEditViewClick = (ann: MikeEditAnnotation, filename: string) => {
|
||||
const handleEditViewClick = (ann: EditAnnotation, filename: string) => {
|
||||
openTab(ann.document_id, filename, undefined, ann.version_id ?? null);
|
||||
setEditScrollTarget({
|
||||
key: `${ann.edit_id}-${Date.now()}`,
|
||||
|
|
@ -753,77 +753,54 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between px-8 py-4 shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-2xl font-medium font-serif">
|
||||
<button
|
||||
onClick={() => router.push("/projects")}
|
||||
className="text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Projects
|
||||
</button>
|
||||
<span className="text-gray-300">›</span>
|
||||
{project ? (
|
||||
<button
|
||||
onClick={() =>
|
||||
router.push(`/projects/${projectId}`)
|
||||
}
|
||||
className="text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
{project.name}
|
||||
{project.cm_number && (
|
||||
<span className="ml-1 text-gray-400">
|
||||
(#{project.cm_number})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="h-6 w-32 rounded bg-gray-100 animate-pulse" />
|
||||
)}
|
||||
<span className="text-gray-300">›</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
router.push(`/projects/${projectId}?tab=assistant`)
|
||||
}
|
||||
className="text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Assistant
|
||||
</button>
|
||||
<span className="text-gray-300">›</span>
|
||||
{chatLoaded ? (
|
||||
<span className="text-gray-900 truncate max-w-xs">
|
||||
{chatTitle ?? "Untitled New Chat"}
|
||||
</span>
|
||||
) : (
|
||||
<div className="h-6 w-40 rounded bg-gray-100 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleNewChat}
|
||||
disabled={creatingChat}
|
||||
title="New chat"
|
||||
className="flex items-center justify-center p-1.5 text-gray-500 hover:text-gray-900 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{creatingChat ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteChat}
|
||||
disabled={deletingChat}
|
||||
title="Delete chat"
|
||||
className="flex items-center justify-center p-1.5 text-gray-500 hover:text-red-600 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{deletingChat ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
shrink
|
||||
breadcrumbs={[
|
||||
{
|
||||
label: "Projects",
|
||||
onClick: () => router.push("/projects"),
|
||||
},
|
||||
project
|
||||
? {
|
||||
label: project.name,
|
||||
suffix: project.cm_number ? (
|
||||
<span className="ml-1 text-gray-400">
|
||||
(#{project.cm_number})
|
||||
</span>
|
||||
) : null,
|
||||
onClick: () => router.push(`/projects/${projectId}`),
|
||||
title: "Back to project",
|
||||
}
|
||||
: {
|
||||
loading: true,
|
||||
skeletonClassName: "w-32",
|
||||
onClick: () => router.push(`/projects/${projectId}`),
|
||||
title: "Back to project",
|
||||
},
|
||||
chatLoaded
|
||||
? {
|
||||
label: chatTitle ?? "Untitled New Chat",
|
||||
}
|
||||
: {
|
||||
loading: true,
|
||||
skeletonClassName: "w-40",
|
||||
},
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
type: "new",
|
||||
onClick: handleNewChat,
|
||||
loading: creatingChat,
|
||||
title: "New chat",
|
||||
},
|
||||
{
|
||||
type: "delete",
|
||||
onClick: handleDeleteChat,
|
||||
loading: deletingChat,
|
||||
title: "Delete chat",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Three-panel body */}
|
||||
<div className="flex flex-1 min-h-0 border-t border-gray-200 overflow-hidden">
|
||||
|
|
@ -1124,8 +1101,7 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleChatDrop}
|
||||
>
|
||||
<div className="h-10 flex items-center gap-2 px-4 border-b border-gray-200 shrink-0">
|
||||
<MikeIcon size={16} />
|
||||
<div className="h-10 flex items-center px-4 border-b border-gray-200 shrink-0">
|
||||
<span className="text-xs text-gray-700">
|
||||
Project Assistant
|
||||
</span>
|
||||
|
|
@ -1191,6 +1167,9 @@ export default function ProjectAssistantChatPage({ params }: Props) {
|
|||
}
|
||||
isError={!!(msg as any).error}
|
||||
annotations={msg.annotations}
|
||||
citationStatus={
|
||||
msg.citationStatus
|
||||
}
|
||||
onCitationClick={
|
||||
handleCitationClick
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus, Loader2, ChevronDown, Check, Table2 } from "lucide-react";
|
||||
import { HeaderSearchBtn } from "@/app/components/shared/HeaderSearchBtn";
|
||||
import { ChevronDown, Check, Table2 } from "lucide-react";
|
||||
import { RowActions } from "@/app/components/shared/RowActions";
|
||||
import {
|
||||
deleteTabularReview,
|
||||
|
|
@ -12,16 +11,16 @@ import {
|
|||
listProjects,
|
||||
updateTabularReview,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import type { TabularReview, MikeProject } from "@/app/components/shared/types";
|
||||
import type { TabularReview, Project } from "@/app/components/shared/types";
|
||||
import { ToolbarTabs } from "@/app/components/shared/ToolbarTabs";
|
||||
import { AddNewTRModal } from "@/app/components/tabular/AddNewTRModal";
|
||||
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageHeader } from "@/app/components/shared/PageHeader";
|
||||
|
||||
type Tab = "all" | "in-project" | "standalone";
|
||||
|
||||
const CHECK_W = "w-8 shrink-0";
|
||||
const NAME_COL_W = "w-[300px] shrink-0";
|
||||
const NAME_COL_W = "w-[332px] shrink-0";
|
||||
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: "all", label: "All" },
|
||||
|
|
@ -39,7 +38,7 @@ function formatDate(iso: string) {
|
|||
|
||||
export default function TabularReviewsPage() {
|
||||
const [reviews, setReviews] = useState<TabularReview[]>([]);
|
||||
const [projects, setProjects] = useState<MikeProject[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newTROpen, setNewTROpen] = useState(false);
|
||||
|
|
@ -56,6 +55,7 @@ export default function TabularReviewsPage() {
|
|||
const actionsRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const stickyCellBg = "bg-[#fcfcfd]";
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
|
|
@ -266,27 +266,28 @@ export default function TabularReviewsPage() {
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-white">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Page header */}
|
||||
<div className="mb-1 flex items-center justify-between px-4 py-3 md:px-10">
|
||||
<PageHeader
|
||||
actions={[
|
||||
{
|
||||
type: "search",
|
||||
value: search,
|
||||
onChange: setSearch,
|
||||
placeholder: "Search reviews…",
|
||||
},
|
||||
{
|
||||
type: "new",
|
||||
onClick: () => setNewTROpen(true),
|
||||
loading: creating,
|
||||
title: "New tabular review",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<h1 className="text-2xl font-medium font-serif text-gray-900">
|
||||
Tabular Reviews
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<HeaderSearchBtn value={search} onChange={setSearch} placeholder="Search reviews…" />
|
||||
<button
|
||||
onClick={() => setNewTROpen(true)}
|
||||
disabled={creating}
|
||||
className="flex items-center justify-center p-1.5 text-gray-500 hover:text-gray-900 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{creating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<ToolbarTabs
|
||||
tabs={TABS}
|
||||
|
|
@ -299,8 +300,10 @@ export default function TabularReviewsPage() {
|
|||
<div className="w-full overflow-x-auto">
|
||||
<div className="min-w-max">
|
||||
<div className="flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
|
||||
<div className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}>
|
||||
{!loading && (
|
||||
<div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}>
|
||||
{loading ? (
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
|
|
@ -311,9 +314,7 @@ export default function TabularReviewsPage() {
|
|||
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white pl-2 text-left`}>
|
||||
Name
|
||||
<span>Name</span>
|
||||
</div>
|
||||
<div className="ml-auto w-24 shrink-0">Columns</div>
|
||||
<div className="w-24 shrink-0">Documents</div>
|
||||
|
|
@ -329,8 +330,8 @@ export default function TabularReviewsPage() {
|
|||
key={i}
|
||||
className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
|
||||
>
|
||||
<div className="w-8 shrink-0" />
|
||||
<div className="flex-1 min-w-0 pl-3 pr-4">
|
||||
<div className={`${NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}>
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-3.5 w-48 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-24 shrink-0">
|
||||
|
|
@ -383,7 +384,7 @@ export default function TabularReviewsPage() {
|
|||
);
|
||||
const rowBg = selectedIds.includes(review.id)
|
||||
? "bg-gray-50"
|
||||
: "bg-white";
|
||||
: stickyCellBg;
|
||||
return (
|
||||
<div
|
||||
key={review.id}
|
||||
|
|
@ -395,57 +396,57 @@ export default function TabularReviewsPage() {
|
|||
: `/tabular-reviews/${review.id}`,
|
||||
);
|
||||
}}
|
||||
className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-50 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 cursor-pointer transition-colors"
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${rowBg} group-hover:bg-gray-50`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(
|
||||
review.id,
|
||||
)}
|
||||
onChange={() =>
|
||||
toggleOne(review.id)
|
||||
}
|
||||
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
</div>
|
||||
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`}>
|
||||
{renamingId === review.id ? (
|
||||
<div className={`sticky left-0 z-[60] ${NAME_COL_W} ${rowBg} py-2 pl-4 pr-2 transition-colors group-hover:bg-gray-100`}>
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) =>
|
||||
setRenameValue(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter")
|
||||
handleRenameSubmit(
|
||||
review.id,
|
||||
);
|
||||
if (e.key === "Escape")
|
||||
setRenamingId(null);
|
||||
}}
|
||||
onBlur={() =>
|
||||
handleRenameSubmit(
|
||||
review.id,
|
||||
)
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(
|
||||
review.id,
|
||||
)}
|
||||
onChange={() =>
|
||||
toggleOne(review.id)
|
||||
}
|
||||
onClick={(e) =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
className="w-full text-sm text-gray-800 bg-transparent outline-none"
|
||||
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-gray-800 truncate block">
|
||||
{review.title ??
|
||||
"Untitled Review"}
|
||||
</span>
|
||||
)}
|
||||
{renamingId === review.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) =>
|
||||
setRenameValue(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter")
|
||||
handleRenameSubmit(
|
||||
review.id,
|
||||
);
|
||||
if (e.key === "Escape")
|
||||
setRenamingId(null);
|
||||
}}
|
||||
onBlur={() =>
|
||||
handleRenameSubmit(
|
||||
review.id,
|
||||
)
|
||||
}
|
||||
onClick={(e) =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
className="min-w-0 flex-1 text-sm text-gray-800 bg-transparent outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-gray-800">
|
||||
{review.title ??
|
||||
"Untitled Review"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto w-24 shrink-0 text-sm text-gray-500 truncate">
|
||||
{review.columns_config?.length ?? 0}
|
||||
|
|
|
|||
|
|
@ -9,13 +9,14 @@ import { ShareWorkflowModal } from "@/app/components/workflows/ShareWorkflowModa
|
|||
import { WFEditColumnModal } from "@/app/components/workflows/WFEditColumnModal";
|
||||
import { WFColumnViewModal } from "@/app/components/workflows/WFColumnViewModal";
|
||||
import { AddColumnModal } from "@/app/components/tabular/AddColumnModal";
|
||||
import type { ColumnConfig, MikeWorkflow } from "@/app/components/shared/types";
|
||||
import type { ColumnConfig, Workflow } from "@/app/components/shared/types";
|
||||
import {
|
||||
BUILT_IN_IDS,
|
||||
BUILT_IN_WORKFLOWS,
|
||||
} from "@/app/components/workflows/builtinWorkflows";
|
||||
import { formatIcon, formatLabel } from "@/app/components/tabular/columnFormat";
|
||||
import { RenameableTitle } from "@/app/components/shared/RenameableTitle";
|
||||
import { PageHeader } from "@/app/components/shared/PageHeader";
|
||||
// dynamic import keeps Tiptap (browser-only) out of the SSR bundle
|
||||
const WorkflowPromptEditor = dynamic(
|
||||
() =>
|
||||
|
|
@ -31,8 +32,7 @@ interface Props {
|
|||
|
||||
type SaveStatus = "idle" | "saving" | "saved";
|
||||
|
||||
const CHECK_W = "w-8 shrink-0";
|
||||
const NAME_COL_W = "w-[300px] shrink-0";
|
||||
const NAME_COL_W = "w-[332px] shrink-0";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
|
|
@ -40,8 +40,9 @@ const NAME_COL_W = "w-[300px] shrink-0";
|
|||
export default function WorkflowDetailPage({ params }: Props) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const stickyCellBg = "bg-[#fcfcfd]";
|
||||
|
||||
const [workflow, setWorkflow] = useState<MikeWorkflow | null>(null);
|
||||
const [workflow, setWorkflow] = useState<Workflow | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
|
||||
|
|
@ -191,13 +192,13 @@ export default function WorkflowDetailPage({ params }: Props) {
|
|||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex items-center justify-between px-8 py-4 shrink-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-6 w-24 rounded bg-gray-100 animate-pulse" />
|
||||
<span className="text-gray-300">›</span>
|
||||
<div className="h-6 w-40 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
shrink
|
||||
breadcrumbs={[
|
||||
{ label: "Workflows" },
|
||||
{ loading: true, skeletonClassName: "w-40" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Toolbar skeleton */}
|
||||
<div className="flex items-center px-8 h-10 border-b border-gray-200 shrink-0">
|
||||
|
|
@ -206,8 +207,8 @@ export default function WorkflowDetailPage({ params }: Props) {
|
|||
|
||||
{/* Table header skeleton */}
|
||||
<div className="flex items-center h-8 pr-8 border-b border-gray-200 shrink-0">
|
||||
<div className="w-8 shrink-0 border-r border-gray-100 self-stretch" />
|
||||
<div className="flex-1 pl-3">
|
||||
<div className={`${NAME_COL_W} flex shrink-0 items-center gap-4 self-stretch pl-4 pr-2`}>
|
||||
<div className="h-2.5 w-2.5 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-2.5 w-20 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-36 shrink-0">
|
||||
|
|
@ -223,8 +224,8 @@ export default function WorkflowDetailPage({ params }: Props) {
|
|||
<div className="flex-1 overflow-hidden">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex items-center h-10 pr-8 border-b border-gray-50">
|
||||
<div className="w-8 shrink-0 border-r border-gray-100 self-stretch" />
|
||||
<div className="flex-1 pl-3 pr-4">
|
||||
<div className={`${NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}>
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-3 rounded bg-gray-100 animate-pulse" style={{ width: `${40 + (i * 13) % 35}%` }} />
|
||||
</div>
|
||||
<div className="w-36 shrink-0">
|
||||
|
|
@ -252,52 +253,58 @@ export default function WorkflowDetailPage({ params }: Props) {
|
|||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between px-8 py-4 shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-2xl font-medium font-serif">
|
||||
<button
|
||||
onClick={() => router.push("/workflows")}
|
||||
className="text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Workflows
|
||||
</button>
|
||||
<span className="text-gray-300">›</span>
|
||||
{readOnly ? (
|
||||
<span className="text-gray-900 truncate max-w-xs">{workflow.title}</span>
|
||||
) : (
|
||||
<RenameableTitle value={workflow.title} onCommit={handleTitleCommit} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Save status */}
|
||||
<span className="text-xs text-gray-400">
|
||||
{saveStatus === "saving"
|
||||
? "Saving…"
|
||||
: saveStatus === "saved"
|
||||
? "Saved"
|
||||
: ""}
|
||||
</span>
|
||||
|
||||
{/* Share button (custom workflows only) */}
|
||||
{canShare && (
|
||||
<button
|
||||
onClick={() => setShareOpen(true)}
|
||||
aria-label="Open workflow people"
|
||||
title="People"
|
||||
className="flex items-center text-gray-500 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{shareOpen && (
|
||||
<ShareWorkflowModal
|
||||
workflowId={id}
|
||||
workflowName={workflow.title}
|
||||
onClose={() => setShareOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
shrink
|
||||
actionGap="md"
|
||||
breadcrumbs={[
|
||||
{
|
||||
label: "Workflows",
|
||||
onClick: () => router.push("/workflows"),
|
||||
title: "Back to Workflows",
|
||||
},
|
||||
{
|
||||
label: readOnly ? (
|
||||
<span className="text-gray-900 truncate max-w-xs">
|
||||
{workflow.title}
|
||||
</span>
|
||||
) : (
|
||||
<RenameableTitle
|
||||
value={workflow.title}
|
||||
onCommit={handleTitleCommit}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
type: "custom",
|
||||
render: (
|
||||
<span className="text-xs text-gray-400">
|
||||
{saveStatus === "saving"
|
||||
? "Saving…"
|
||||
: saveStatus === "saved"
|
||||
? "Saved"
|
||||
: ""}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
canShare
|
||||
? {
|
||||
onClick: () => setShareOpen(true),
|
||||
title: "Open workflow people",
|
||||
iconOnly: true,
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
}
|
||||
: null,
|
||||
]}
|
||||
/>
|
||||
{shareOpen && (
|
||||
<ShareWorkflowModal
|
||||
workflowId={id}
|
||||
workflowName={workflow.title}
|
||||
onClose={() => setShareOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Read-only badge for built-in workflows */}
|
||||
{readOnly && (
|
||||
|
|
@ -366,7 +373,7 @@ export default function WorkflowDetailPage({ params }: Props) {
|
|||
<div className="min-w-max flex min-h-full flex-col">
|
||||
{/* Table header */}
|
||||
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium shrink-0 select-none">
|
||||
<div className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}>
|
||||
<div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}>
|
||||
{columns.length > 0 && (
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -376,9 +383,7 @@ export default function WorkflowDetailPage({ params }: Props) {
|
|||
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white pl-2 text-left`}>
|
||||
Column Title
|
||||
<span>Column Title</span>
|
||||
</div>
|
||||
<div className="ml-auto w-36 shrink-0">Format</div>
|
||||
<div className="flex-1 min-w-0">Prompt</div>
|
||||
|
|
@ -413,23 +418,21 @@ export default function WorkflowDetailPage({ params }: Props) {
|
|||
<div
|
||||
key={col.index}
|
||||
onClick={() => readOnly ? setViewingColumn(col) : setEditingColumn(col)}
|
||||
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-100 cursor-pointer transition-colors"
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${isChecked ? "bg-gray-50" : "bg-white"} group-hover:bg-gray-50`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => setSelectedColIndices((prev) => prev.includes(col.index) ? prev.filter((i) => i !== col.index) : [...prev, col.index])}
|
||||
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
</div>
|
||||
<div className={`sticky left-8 z-[60] ${NAME_COL_W} p-2 ${isChecked ? "bg-gray-50" : "bg-white"} group-hover:bg-gray-50`}>
|
||||
<span className="text-sm text-gray-800 truncate block">
|
||||
{col.name}
|
||||
</span>
|
||||
<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="flex min-w-0 items-center gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => setSelectedColIndices((prev) => prev.includes(col.index) ? prev.filter((i) => i !== col.index) : [...prev, col.index])}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-gray-800">
|
||||
{col.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto w-36 shrink-0">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-gray-600">
|
||||
|
|
|
|||
|
|
@ -9,15 +9,21 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { uploadStandaloneDocument } from "@/app/lib/mikeApi";
|
||||
import type { MikeDocument } from "../shared/types";
|
||||
import type { Document } from "../shared/types";
|
||||
|
||||
interface Props {
|
||||
onSelectDoc: (doc: MikeDocument) => void;
|
||||
onSelectDoc: (doc: Document) => void;
|
||||
onBrowseAll: () => void;
|
||||
selectedDocIds?: string[];
|
||||
hideLabel?: boolean;
|
||||
}
|
||||
|
||||
export function AddDocButton({ onSelectDoc, onBrowseAll, selectedDocIds = [] }: Props) {
|
||||
export function AddDocButton({
|
||||
onSelectDoc,
|
||||
onBrowseAll,
|
||||
selectedDocIds = [],
|
||||
hideLabel = false,
|
||||
}: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -67,7 +73,7 @@ export function AddDocButton({ onSelectDoc, onBrowseAll, selectedDocIds = [] }:
|
|||
className={`h-4 w-4 shrink-0 transition-transform duration-300 ${isOpen ? "rotate-[135deg]" : ""}`}
|
||||
/>
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
<span className={hideLabel ? "hidden" : "hidden sm:inline"}>
|
||||
{selectedDocIds.length === 1
|
||||
? "Document"
|
||||
: "Documents"}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,12 +1,23 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { DocPanel, type DocPanelMode } from "../shared/DocPanel";
|
||||
import type {
|
||||
MikeCitationAnnotation,
|
||||
MikeEditAnnotation,
|
||||
CitationAnnotation,
|
||||
EditAnnotation,
|
||||
} from "../shared/types";
|
||||
import {
|
||||
CaseLawPanel,
|
||||
type CaseTab,
|
||||
} from "./CaseLawPanel";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab data
|
||||
|
|
@ -34,15 +45,19 @@ export type DocumentTab = CommonTab & { kind: "document" };
|
|||
|
||||
export type CitationTab = CommonTab & {
|
||||
kind: "citation";
|
||||
citation: MikeCitationAnnotation;
|
||||
citation: CitationAnnotation;
|
||||
};
|
||||
|
||||
export type EditTab = CommonTab & {
|
||||
kind: "edit";
|
||||
edit: MikeEditAnnotation;
|
||||
edit: EditAnnotation;
|
||||
};
|
||||
|
||||
export type AssistantSidePanelTab = DocumentTab | CitationTab | EditTab;
|
||||
export type AssistantSidePanelTab =
|
||||
| DocumentTab
|
||||
| CitationTab
|
||||
| EditTab
|
||||
| CaseTab;
|
||||
|
||||
interface Props {
|
||||
tabs: AssistantSidePanelTab[];
|
||||
|
|
@ -86,6 +101,22 @@ interface Props {
|
|||
|
||||
const MIN_WIDTH = 300;
|
||||
const MAX_WIDTH_OFFSET = 56; // sidebar width
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
|
||||
function maxPanelWidth() {
|
||||
if (typeof window === "undefined") return 600;
|
||||
return Math.max(
|
||||
MIN_WIDTH,
|
||||
window.innerWidth - MAX_WIDTH_OFFSET - MIN_CHAT_WIDTH,
|
||||
);
|
||||
}
|
||||
|
||||
function tabTitle(tab: AssistantSidePanelTab): string {
|
||||
if (tab.kind === "case") {
|
||||
return tab.caseName || tab.citation || "Case";
|
||||
}
|
||||
return tab.filename;
|
||||
}
|
||||
|
||||
export function AssistantSidePanel({
|
||||
tabs,
|
||||
|
|
@ -104,7 +135,10 @@ export function AssistantSidePanel({
|
|||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [panelWidth, setPanelWidth] = useState(() =>
|
||||
typeof window !== "undefined"
|
||||
? Math.round((window.innerWidth - MAX_WIDTH_OFFSET) / 2)
|
||||
? Math.min(
|
||||
maxPanelWidth(),
|
||||
Math.round((window.innerWidth - MAX_WIDTH_OFFSET) / 2),
|
||||
)
|
||||
: 600,
|
||||
);
|
||||
|
||||
|
|
@ -120,10 +154,9 @@ export function AssistantSidePanel({
|
|||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
const delta = dragStartX.current - ev.clientX;
|
||||
const maxWidth = window.innerWidth - MAX_WIDTH_OFFSET - 200;
|
||||
setPanelWidth(
|
||||
Math.min(
|
||||
maxWidth,
|
||||
maxPanelWidth(),
|
||||
Math.max(MIN_WIDTH, dragStartWidth.current + delta),
|
||||
),
|
||||
);
|
||||
|
|
@ -143,46 +176,73 @@ export function AssistantSidePanel({
|
|||
[panelWidth],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
setPanelWidth((width) =>
|
||||
Math.min(maxPanelWidth(), Math.max(MIN_WIDTH, width)),
|
||||
);
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
onResize();
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
|
||||
const active = tabs.find((t) => t.id === activeTabId) ?? tabs[0] ?? null;
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="flex h-full shrink-0 flex-col bg-white relative border-l border-gray-200 shadow-[-4px_0_12px_rgba(0,0,0,0.02)]"
|
||||
style={{ width: panelWidth }}
|
||||
className={cn(
|
||||
"relative flex h-full w-full shrink-0 flex-col md:my-3 md:mr-3 md:h-[calc(100%-1.5rem)] md:w-[var(--assistant-panel-width)]",
|
||||
"rounded-2xl border border-white/70 bg-white shadow-[0_6px_18px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18),inset_1px_0_0_rgba(255,255,255,0.5)] backdrop-blur-2xl overflow-hidden",
|
||||
)}
|
||||
style={{
|
||||
"--assistant-panel-width": `${panelWidth}px`,
|
||||
} as CSSProperties}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
onMouseDown={onMouseDown}
|
||||
className="absolute left-0 top-0 h-full w-1 cursor-col-resize hover:bg-blue-400 transition-colors z-10"
|
||||
className={cn(
|
||||
"absolute left-0 top-0 z-10 hidden h-full w-1 cursor-col-resize transition-colors md:block",
|
||||
"hover:bg-blue-400/70",
|
||||
)}
|
||||
style={{ marginLeft: -2 }}
|
||||
/>
|
||||
|
||||
{/* Tab strip (Chrome-style) */}
|
||||
<div className="flex items-end gap-1 pr-2 pt-2 bg-gray-100">
|
||||
<div className="flex-1 flex items-end gap-1 overflow-x-auto pl-2 pr-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-end gap-1 px-1 pt-2",
|
||||
"bg-gray-200/80",
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 flex items-end gap-1 overflow-hidden px-2">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === active.id;
|
||||
const showVersionBadge =
|
||||
tab.kind !== "case" &&
|
||||
typeof tab.versionNumber === "number" &&
|
||||
Number.isFinite(tab.versionNumber) &&
|
||||
tab.versionNumber > 1;
|
||||
const title = tabTitle(tab);
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
onClick={() => onActivateTab(tab.id)}
|
||||
className={`group relative flex items-center gap-1.5 pl-3 pr-1.5 h-8 min-w-0 max-w-[220px] rounded-t-lg cursor-pointer select-none transition-colors ${
|
||||
className={cn(
|
||||
"group relative flex items-center gap-1.5 pl-3 pr-1.5 h-8 min-w-0 max-w-[220px] rounded-t-lg cursor-pointer select-none transition-colors",
|
||||
isActive
|
||||
? "bg-white text-gray-800 before:content-[''] before:absolute before:bottom-0 before:-left-2 before:w-2 before:h-2 before:bg-[radial-gradient(circle_at_top_left,transparent_8px,white_9px)] after:content-[''] after:absolute after:bottom-0 after:-right-2 after:w-2 after:h-2 after:bg-[radial-gradient(circle_at_top_right,transparent_8px,white_9px)]"
|
||||
: "bg-gray-200/70 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
? "z-20 bg-white text-gray-800 before:content-[''] before:absolute before:bottom-0 before:-left-2 before:z-20 before:h-2 before:w-2 before:rounded-br-lg before:shadow-[4px_4px_0_4px_white] before:transition-shadow after:content-[''] after:absolute after:bottom-0 after:-right-2 after:z-20 after:h-2 after:w-2 after:rounded-bl-lg after:shadow-[-4px_4px_0_4px_white] after:transition-shadow"
|
||||
: "z-10 bg-gray-100 text-gray-600 hover:bg-gray-100 before:content-[''] before:absolute before:bottom-0 before:-left-2 before:h-2 before:w-2 before:rounded-br-lg before:shadow-[4px_4px_0_4px_#f3f4f6] before:transition-shadow after:content-[''] after:absolute after:bottom-0 after:-right-2 after:h-2 after:w-2 after:rounded-bl-lg after:shadow-[-4px_4px_0_4px_#f3f4f6] after:transition-shadow",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate text-xs ${isActive ? "font-medium" : "font-normal"}`}
|
||||
title={tab.filename}
|
||||
title={title}
|
||||
>
|
||||
{tab.filename}
|
||||
{title}
|
||||
</span>
|
||||
{showVersionBadge && (
|
||||
<span
|
||||
|
|
@ -200,7 +260,7 @@ export function AssistantSidePanel({
|
|||
e.stopPropagation();
|
||||
onCloseTab(tab.id);
|
||||
}}
|
||||
className="shrink-0 rounded-full p-0.5 text-gray-400 hover:bg-gray-300 hover:text-gray-700"
|
||||
className="shrink-0 rounded-full p-0.5 text-gray-400 hover:text-gray-700"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
|
|
@ -210,7 +270,7 @@ export function AssistantSidePanel({
|
|||
</div>
|
||||
<button
|
||||
onClick={onCloseAll}
|
||||
className="shrink-0 mb-1 ml-1 rounded-lg p-1.5 text-gray-400 hover:bg-gray-200 hover:text-gray-700"
|
||||
className="shrink-0 mb-1 ml-1 rounded-lg p-1.5 text-gray-400 hover:text-gray-700"
|
||||
title="Close panel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
|
@ -223,6 +283,20 @@ export function AssistantSidePanel({
|
|||
<div className="flex-1 min-h-0 relative">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === active.id;
|
||||
if (tab.kind === "case") {
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`absolute inset-0 flex flex-col ${isActive ? "" : "invisible pointer-events-none"}`}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<CaseLawPanel
|
||||
tab={tab}
|
||||
compactActions={panelWidth < 600}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const mode: DocPanelMode =
|
||||
tab.kind === "citation"
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ChevronLeft, Search, X } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { MikeWorkflow } from "../shared/types";
|
||||
import type { Workflow } from "../shared/types";
|
||||
import { listWorkflows } from "@/app/lib/mikeApi";
|
||||
import { BUILT_IN_WORKFLOWS } from "../workflows/builtinWorkflows";
|
||||
import { Modal } from "../shared/Modal";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (workflow: MikeWorkflow) => void;
|
||||
onSelect: (workflow: Workflow) => void;
|
||||
projectName?: string;
|
||||
projectCmNumber?: string | null;
|
||||
initialWorkflowId?: string;
|
||||
|
|
@ -26,9 +26,9 @@ export function AssistantWorkflowModal({
|
|||
projectCmNumber,
|
||||
initialWorkflowId,
|
||||
}: Props) {
|
||||
const [workflows, setWorkflows] = useState<MikeWorkflow[]>([]);
|
||||
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selected, setSelected] = useState<MikeWorkflow | null>(null);
|
||||
const [selected, setSelected] = useState<Workflow | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [rightVisible, setRightVisible] = useState(false);
|
||||
|
||||
|
|
@ -87,45 +87,28 @@ export function AssistantWorkflowModal({
|
|||
onClose();
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs">
|
||||
<div
|
||||
className={`w-full rounded-2xl bg-white shadow-2xl flex flex-col h-[600px] ${selected ? "max-w-4xl" : "max-w-2xl"}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-4 shrink-0 border-b border-gray-100">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
{projectName ? (
|
||||
<>
|
||||
<span>Projects</span>
|
||||
<span>›</span>
|
||||
<span>
|
||||
{projectName}
|
||||
{projectCmNumber
|
||||
? ` (#${projectCmNumber})`
|
||||
: ""}
|
||||
</span>
|
||||
<span>›</span>
|
||||
<span>Assistant</span>
|
||||
<span>›</span>
|
||||
<span>Add workflow</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Assistant</span>
|
||||
<span>›</span>
|
||||
<span>Add workflow</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
const breadcrumbs = projectName
|
||||
? [
|
||||
"Projects",
|
||||
`${projectName}${projectCmNumber ? ` (#${projectCmNumber})` : ""}`,
|
||||
"Assistant",
|
||||
"Add workflow",
|
||||
]
|
||||
: ["Assistant", "Add workflow"];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
size={selected ? "xl" : "lg"}
|
||||
breadcrumbs={breadcrumbs}
|
||||
primaryAction={{
|
||||
label: "Use",
|
||||
type: "button",
|
||||
onClick: handleUse,
|
||||
disabled: !selected,
|
||||
}}
|
||||
>
|
||||
{/* Content */}
|
||||
<div className="flex flex-row flex-1 min-h-0 overflow-hidden">
|
||||
{/* Left panel — workflow list */}
|
||||
|
|
@ -133,7 +116,7 @@ export function AssistantWorkflowModal({
|
|||
className={`overflow-y-auto ${selected ? "w-80 shrink-0" : "flex-1"}`}
|
||||
>
|
||||
{/* Search */}
|
||||
<div className="px-4 pt-3 pb-2 shrink-0">
|
||||
<div className="pt-3 pb-2 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
|
||||
|
|
@ -152,7 +135,7 @@ export function AssistantWorkflowModal({
|
|||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-px px-4 pt-1">
|
||||
<div className="space-y-px pt-1">
|
||||
{[60, 45, 75, 50, 65, 40, 55].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
|
|
@ -167,7 +150,7 @@ export function AssistantWorkflowModal({
|
|||
))}
|
||||
</div>
|
||||
) : filteredWorkflows.length === 0 ? (
|
||||
<p className="px-4 py-8 text-sm text-center text-gray-400">
|
||||
<p className="py-8 text-sm text-center text-gray-400">
|
||||
{search ? "No matches found" : "No assistant workflows found"}
|
||||
</p>
|
||||
) : (
|
||||
|
|
@ -268,26 +251,6 @@ export function AssistantWorkflowModal({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-gray-100 px-4 py-3 flex items-center justify-end gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUse}
|
||||
disabled={!selected}
|
||||
className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Use
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
623
frontend/src/app/components/assistant/CaseLawPanel.tsx
Normal file
623
frontend/src/app/components/assistant/CaseLawPanel.tsx
Normal file
|
|
@ -0,0 +1,623 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import {
|
||||
Download,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { MikeIcon } from "@/components/chat/mike-icon";
|
||||
import type { CaseCitationQuote } from "../shared/types";
|
||||
import {
|
||||
clearDocxQuoteHighlights,
|
||||
highlightDocxQuote,
|
||||
} from "../shared/highlightDocxQuote";
|
||||
import {
|
||||
RelevantQuotes,
|
||||
type RelevantQuoteItem,
|
||||
} from "../shared/RelevantQuotes";
|
||||
import {
|
||||
getCourtlistenerOpinions,
|
||||
type CaseLawOpinion,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type CaseTab = {
|
||||
kind: "case";
|
||||
id: `case:${number}`;
|
||||
chatId: string;
|
||||
clusterId: number;
|
||||
citationRef?: number;
|
||||
caseName: string | null;
|
||||
citation: string | null;
|
||||
url: string | null;
|
||||
dateFiled: string | null;
|
||||
pdfUrl: string | null;
|
||||
judges: string | null;
|
||||
quotes?: CaseCitationQuote[];
|
||||
opinions?: CaseLawOpinion[];
|
||||
};
|
||||
|
||||
const courtlistenerOpinionsCache = new Map<number, CaseLawOpinion[]>();
|
||||
const caseOpinionsRequestCache = new Map<
|
||||
string,
|
||||
ReturnType<typeof getCourtlistenerOpinions>
|
||||
>();
|
||||
|
||||
const CASE_OPINION_SANITIZER_CONFIG = {
|
||||
ALLOWED_TAGS: [
|
||||
"a",
|
||||
"blockquote",
|
||||
"br",
|
||||
"code",
|
||||
"div",
|
||||
"em",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"i",
|
||||
"li",
|
||||
"ol",
|
||||
"p",
|
||||
"pre",
|
||||
"small",
|
||||
"span",
|
||||
"strong",
|
||||
"sub",
|
||||
"sup",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
"th",
|
||||
"thead",
|
||||
"tr",
|
||||
"u",
|
||||
"ul",
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
"aria-label",
|
||||
"class",
|
||||
"colspan",
|
||||
"href",
|
||||
"id",
|
||||
"rel",
|
||||
"rowspan",
|
||||
"target",
|
||||
"title",
|
||||
],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
ALLOW_ARIA_ATTR: true,
|
||||
ALLOWED_URI_REGEXP: /^(?:https:\/\/www\.courtlistener\.com\/|#)/i,
|
||||
FORBID_ATTR: ["style"],
|
||||
FORBID_TAGS: [
|
||||
"embed",
|
||||
"form",
|
||||
"iframe",
|
||||
"math",
|
||||
"object",
|
||||
"script",
|
||||
"style",
|
||||
"svg",
|
||||
],
|
||||
RETURN_TRUSTED_TYPE: false,
|
||||
};
|
||||
|
||||
function sanitizeCaseOpinionHtml(value: string): string {
|
||||
const sanitized = DOMPurify.sanitize(
|
||||
value,
|
||||
CASE_OPINION_SANITIZER_CONFIG,
|
||||
);
|
||||
if (typeof document === "undefined") return sanitized;
|
||||
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = sanitized;
|
||||
template.content.querySelectorAll("a[href]").forEach((anchor) => {
|
||||
const href = anchor.getAttribute("href") ?? "";
|
||||
if (href.startsWith("#")) return;
|
||||
anchor.setAttribute("target", "_blank");
|
||||
anchor.setAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
return template.innerHTML;
|
||||
}
|
||||
|
||||
function friendlyCaseError(message: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(message) as { detail?: unknown };
|
||||
if (typeof parsed.detail === "string") {
|
||||
message = parsed.detail;
|
||||
}
|
||||
} catch {
|
||||
/* keep original message */
|
||||
}
|
||||
|
||||
if (message.includes("429") || /rate limit|throttled/i.test(message)) {
|
||||
const waitMatch = message.match(/available in\s+(\d+)\s+seconds/i);
|
||||
const wait = waitMatch?.[1];
|
||||
return wait
|
||||
? `CourtListener is rate limiting requests. Please try again in about ${wait} seconds.`
|
||||
: "CourtListener is rate limiting requests. Please try again shortly.";
|
||||
}
|
||||
if (message.includes("401") || /credentials|token|auth/i.test(message)) {
|
||||
return "CourtListener authentication is not configured correctly.";
|
||||
}
|
||||
return "Could not load this case from CourtListener. Please try again shortly.";
|
||||
}
|
||||
|
||||
function formatCaseDate(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const date = new Date(`${value}T00:00:00`);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function hashString(value: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
function caseTabQuoteKey(tab: CaseTab): string {
|
||||
const quoteKey =
|
||||
tab.quotes
|
||||
?.map((quote) => quote.quote)
|
||||
.filter(Boolean)
|
||||
.join("\n---\n") ?? "";
|
||||
return [tab.clusterId, tab.citationRef ?? "source", hashString(quoteKey)].join(":");
|
||||
}
|
||||
|
||||
function relevantQuoteKey(quote: CaseCitationQuote, index: number): string {
|
||||
return `${quote.opinionId ?? "unknown"}:${index}:${hashString(quote.quote)}`;
|
||||
}
|
||||
|
||||
function caseCitationRequestKey(tab: CaseTab) {
|
||||
return String(tab.clusterId);
|
||||
}
|
||||
|
||||
export function CaseLawPanel({
|
||||
tab,
|
||||
compactActions = false,
|
||||
}: {
|
||||
tab: CaseTab;
|
||||
compactActions?: boolean;
|
||||
}) {
|
||||
const cachedOpinions = courtlistenerOpinionsCache.get(tab.clusterId);
|
||||
const [opinions, setOpinions] = useState<CaseLawOpinion[]>(
|
||||
tab.opinions?.length ? tab.opinions : (cachedOpinions ?? []),
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeOpinionId, setActiveOpinionId] = useState<number | null>(null);
|
||||
const [relevantQuotes, setRelevantQuotes] = useState<CaseCitationQuote[]>(
|
||||
tab.quotes ?? [],
|
||||
);
|
||||
const [activeQuoteKey, setActiveQuoteKey] = useState<string | null>(null);
|
||||
const [quoteIndexState, setQuoteIndexState] = useState({
|
||||
cacheKey: "",
|
||||
index: 0,
|
||||
});
|
||||
const opinionScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const opinionContentRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab.opinions?.length) {
|
||||
setOpinions(tab.opinions);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
const cached = courtlistenerOpinionsCache.get(tab.clusterId);
|
||||
if (cached?.length) {
|
||||
setOpinions(cached);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const requestKey = caseCitationRequestKey(tab);
|
||||
let request = caseOpinionsRequestCache.get(requestKey);
|
||||
if (!request) {
|
||||
request = getCourtlistenerOpinions(tab.clusterId).finally(() => {
|
||||
caseOpinionsRequestCache.delete(requestKey);
|
||||
});
|
||||
caseOpinionsRequestCache.set(requestKey, request);
|
||||
}
|
||||
request
|
||||
.then((nextOpinions) => {
|
||||
if (!cancelled) {
|
||||
setOpinions(nextOpinions);
|
||||
courtlistenerOpinionsCache.set(tab.clusterId, nextOpinions);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? friendlyCaseError(err.message)
|
||||
: "Failed to load case",
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [tab]);
|
||||
|
||||
useEffect(() => {
|
||||
const firstOpinionId =
|
||||
orderOpinions(opinions).find(
|
||||
({ opinion }) => typeof opinion.opinionId === "number",
|
||||
)?.opinion.opinionId ?? null;
|
||||
setActiveOpinionId(firstOpinionId);
|
||||
}, [opinions]);
|
||||
|
||||
useEffect(() => {
|
||||
setRelevantQuotes(tab.quotes ?? []);
|
||||
}, [tab.quotes]);
|
||||
|
||||
const title = tab.caseName;
|
||||
const citation = tab.citation;
|
||||
const courtlistenerUrl = tab.url;
|
||||
const filedDate = formatCaseDate(tab.dateFiled);
|
||||
const judges = tab.judges?.trim() || null;
|
||||
const orderedOpinions = orderOpinions(opinions);
|
||||
const activeOpinion = opinions.find(
|
||||
(opinion) => opinion.opinionId === activeOpinionId,
|
||||
);
|
||||
const quoteCacheKey = caseTabQuoteKey(tab);
|
||||
const currentQuoteIndex =
|
||||
quoteIndexState.cacheKey === quoteCacheKey
|
||||
? Math.min(
|
||||
quoteIndexState.index,
|
||||
Math.max(relevantQuotes.length - 1, 0),
|
||||
)
|
||||
: 0;
|
||||
const relevantQuoteItems: RelevantQuoteItem[] = relevantQuotes.map(
|
||||
(quote, index) => ({
|
||||
id: relevantQuoteKey(quote, index),
|
||||
quote: quote.quote,
|
||||
eyebrow:
|
||||
quote.author || quote.type
|
||||
? opinionTitle({
|
||||
opinionId: quote.opinionId,
|
||||
type: quote.type,
|
||||
author: quote.author,
|
||||
url: null,
|
||||
})
|
||||
: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const selectRelevantQuote = useCallback(
|
||||
(quote: CaseCitationQuote, index: number) => {
|
||||
const key = relevantQuoteKey(quote, index);
|
||||
setQuoteIndexState({ cacheKey: quoteCacheKey, index });
|
||||
setActiveQuoteKey((current) => (current === key ? null : key));
|
||||
if (typeof quote.opinionId === "number") {
|
||||
setActiveOpinionId(quote.opinionId);
|
||||
}
|
||||
},
|
||||
[quoteCacheKey],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setQuoteIndexState({ cacheKey: quoteCacheKey, index: 0 });
|
||||
const firstQuote = relevantQuotes[0];
|
||||
setActiveQuoteKey(firstQuote ? relevantQuoteKey(firstQuote, 0) : null);
|
||||
if (typeof firstQuote?.opinionId === "number") {
|
||||
setActiveOpinionId(firstQuote.opinionId);
|
||||
}
|
||||
}, [quoteCacheKey, relevantQuotes]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = opinionContentRef.current;
|
||||
if (!root) return;
|
||||
clearDocxQuoteHighlights(root);
|
||||
if (!activeQuoteKey) return;
|
||||
|
||||
const activeEntry = relevantQuotes
|
||||
.map((quote, index) => ({ quote, index }))
|
||||
.find(
|
||||
({ quote, index }) =>
|
||||
relevantQuoteKey(quote, index) === activeQuoteKey,
|
||||
);
|
||||
if (!activeEntry) return;
|
||||
if (
|
||||
typeof activeEntry.quote.opinionId === "number" &&
|
||||
activeEntry.quote.opinionId !== activeOpinionId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const match = highlightDocxQuote(root, activeEntry.quote.quote);
|
||||
if (!match) return;
|
||||
window.setTimeout(() => {
|
||||
match.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, 50);
|
||||
}, [
|
||||
activeOpinionId,
|
||||
activeOpinion?.html,
|
||||
activeOpinion?.opinionId,
|
||||
activeOpinion?.text,
|
||||
activeQuoteKey,
|
||||
relevantQuotes,
|
||||
]);
|
||||
|
||||
const opinionSurfaceClassName = "bg-white/60 backdrop-blur-xl";
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-start gap-3 px-3 pt-4 pb-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="font-serif text-xl text-gray-900">
|
||||
{title}
|
||||
{citation && (
|
||||
<span className="text-gray-500">, {citation}</span>
|
||||
)}
|
||||
</h2>
|
||||
{filedDate || judges ? (
|
||||
<p className="mt-1 font-serif text-sm text-gray-600">
|
||||
{filedDate && <>Date: {filedDate}</>}
|
||||
{filedDate && judges && (
|
||||
<span className="mx-1.5 text-gray-300">|</span>
|
||||
)}
|
||||
{judges && <>Judges: {judges}</>}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex min-w-0 shrink flex-wrap items-center justify-end gap-2">
|
||||
{tab.pdfUrl && (
|
||||
<a
|
||||
href={tab.pdfUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
download
|
||||
aria-label="Download PDF"
|
||||
title="Download PDF"
|
||||
className={`inline-flex min-w-0 shrink items-center justify-center rounded-lg border border-gray-200 text-xs text-gray-700 hover:bg-gray-50 ${
|
||||
compactActions
|
||||
? "h-8 w-8 p-0"
|
||||
: "gap-1.5 px-2.5 py-1.5"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
compactActions ? "sr-only" : "truncate"
|
||||
}
|
||||
>
|
||||
PDF
|
||||
</span>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
{courtlistenerUrl && (
|
||||
<a
|
||||
href={courtlistenerUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Open in CourtListener"
|
||||
title="Open in CourtListener"
|
||||
className={`inline-flex min-w-0 shrink items-center justify-center rounded-lg border border-gray-200 text-xs text-gray-700 hover:bg-gray-50 ${
|
||||
compactActions
|
||||
? "h-8 w-8 p-0"
|
||||
: "gap-1.5 px-2.5 py-1.5"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
compactActions ? "sr-only" : "truncate"
|
||||
}
|
||||
>
|
||||
CourtListener
|
||||
</span>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{relevantQuoteItems.length > 0 && (
|
||||
<RelevantQuotes
|
||||
quotes={relevantQuoteItems}
|
||||
activeQuoteId={activeQuoteKey}
|
||||
currentIndex={currentQuoteIndex}
|
||||
citationRef={tab.citationRef}
|
||||
citationText={[title, citation].filter(Boolean).join(", ")}
|
||||
onSelect={(_quote, index) => {
|
||||
const quote = relevantQuotes[index];
|
||||
if (quote) selectRelevantQuote(quote, index);
|
||||
}}
|
||||
onIndexChange={(index) => {
|
||||
const quote = relevantQuotes[index];
|
||||
if (quote) selectRelevantQuote(quote, index);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!loading && !error && opinions.length > 1 && (
|
||||
<div className="relative mt-2 px-1 shadow-[inset_0_-1px_0_rgb(229_231_235)]">
|
||||
<div className="relative z-10 flex items-end gap-1 overflow-hidden px-2 pt-1">
|
||||
{orderedOpinions.map(({ opinion, index }) => {
|
||||
const opinionId = opinion.opinionId;
|
||||
const isActive =
|
||||
opinionId !== null &&
|
||||
opinionId === activeOpinionId;
|
||||
return (
|
||||
<button
|
||||
key={opinionId ?? index}
|
||||
type="button"
|
||||
disabled={opinionId === null}
|
||||
onClick={() => {
|
||||
if (opinionId === null) return;
|
||||
setActiveOpinionId(opinionId);
|
||||
setActiveQuoteKey(null);
|
||||
}}
|
||||
style={
|
||||
isActive
|
||||
? {
|
||||
filter: "drop-shadow(0 -1px 0 #e5e7eb) drop-shadow(-1px 0 0 #e5e7eb) drop-shadow(1px 0 0 #e5e7eb)",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`group relative flex h-8 max-w-[180px] shrink-0 items-center rounded-t-lg px-3 font-serif text-[13px] transition-colors ${
|
||||
isActive
|
||||
? "z-20 bg-white text-gray-800 before:content-[''] before:absolute before:bottom-0 before:-left-2 before:z-20 before:h-2 before:w-2 before:rounded-br-lg before:shadow-[4px_4px_0_4px_white] before:transition-shadow after:content-[''] after:absolute after:bottom-0 after:-right-2 after:z-20 after:h-2 after:w-2 after:rounded-bl-lg after:shadow-[-4px_4px_0_4px_white] after:transition-shadow"
|
||||
: "z-10 bg-gray-100 text-gray-600 hover:bg-gray-100 before:content-[''] before:absolute before:bottom-0 before:-left-2 before:h-2 before:w-2 before:rounded-br-lg before:shadow-[4px_4px_0_4px_#f3f4f6] before:transition-shadow after:content-[''] after:absolute after:bottom-0 after:-right-2 after:h-2 after:w-2 after:rounded-bl-lg after:shadow-[-4px_4px_0_4px_#f3f4f6] after:transition-shadow"
|
||||
} disabled:cursor-not-allowed disabled:opacity-50`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{opinionTitle(opinion, index)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-1 min-h-0 flex-col px-3 py-3">
|
||||
{loading && (
|
||||
<div className={cn("h-full min-h-0 rounded-lg border border-gray-200", opinionSurfaceClassName)}>
|
||||
<div className="flex h-full items-center justify-center p-5">
|
||||
<MikeIcon spin mike size={28} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p className={cn("rounded-md p-4 font-serif text-sm text-red-600", opinionSurfaceClassName)}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!loading && !error && opinions.length === 0 && (
|
||||
<p className={cn("rounded-md p-4 font-serif text-sm text-gray-500", opinionSurfaceClassName)}>
|
||||
No opinions were returned for this case.
|
||||
</p>
|
||||
)}
|
||||
{!loading && !error && opinions.length > 0 && (
|
||||
<div className={cn("h-full min-h-0 border border-gray-200 rounded-lg overflow-hidden", opinionSurfaceClassName)}>
|
||||
{activeOpinion && (
|
||||
<div
|
||||
ref={opinionScrollRef}
|
||||
className={cn("h-full overflow-y-auto p-5", opinionSurfaceClassName)}
|
||||
>
|
||||
<OpinionBlock
|
||||
opinion={activeOpinion}
|
||||
contentRef={opinionContentRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function opinionTypeLabel(value: string | null): string {
|
||||
if (!value) return "Opinion";
|
||||
const type = value.replace(/^\d+/, "").replace(/_/g, " ").trim();
|
||||
const compactType = type.toLowerCase().replace(/\s+/g, "");
|
||||
if (compactType === "lead") return "Lead Opinion";
|
||||
if (
|
||||
compactType === "concurrentinpart" ||
|
||||
compactType === "concurrenceinpart" ||
|
||||
compactType === "concurinpart"
|
||||
) {
|
||||
return "Concurrence in part";
|
||||
}
|
||||
if (compactType === "combined") return "Combined Opinion";
|
||||
return type.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function opinionOrderRank(value: string | null): number {
|
||||
const type = value?.replace(/^\d+/, "").toLowerCase() ?? "";
|
||||
if (
|
||||
type.includes("lead") ||
|
||||
type.includes("majority") ||
|
||||
type.includes("unanimous") ||
|
||||
type.includes("plurality")
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
if (type.includes("concurr")) return 1;
|
||||
if (type.includes("dissent")) return 2;
|
||||
if (type.includes("combined")) return 4;
|
||||
return 3;
|
||||
}
|
||||
|
||||
function orderOpinions(opinions: CaseLawOpinion[]) {
|
||||
return opinions
|
||||
.map((opinion, index) => ({ opinion, index }))
|
||||
.sort((a, b) => {
|
||||
const rankDelta =
|
||||
opinionOrderRank(a.opinion.type) -
|
||||
opinionOrderRank(b.opinion.type);
|
||||
return rankDelta || a.index - b.index;
|
||||
});
|
||||
}
|
||||
|
||||
function opinionTitle(opinion: CaseLawOpinion, index?: number): string {
|
||||
const type = opinionTypeLabel(opinion.type);
|
||||
const fallbackType = opinion.type ? type : `Opinion ${index ?? ""}`.trim();
|
||||
return opinion.author
|
||||
? `${fallbackType} by ${opinion.author}`
|
||||
: fallbackType;
|
||||
}
|
||||
|
||||
function OpinionBlock({
|
||||
opinion,
|
||||
contentRef,
|
||||
}: {
|
||||
opinion: CaseLawOpinion;
|
||||
contentRef?: RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const sanitizedHtml = useMemo(
|
||||
() =>
|
||||
opinion.html
|
||||
? sanitizeCaseOpinionHtml(opinion.html)
|
||||
: "",
|
||||
[opinion.html],
|
||||
);
|
||||
|
||||
return (
|
||||
<article
|
||||
ref={contentRef}
|
||||
className="case-opinion-content border-b border-gray-100 pb-6 last:border-b-0"
|
||||
>
|
||||
<div className="mb-3">
|
||||
<h3 className="font-serif text-lg font-semibold text-gray-900">
|
||||
{opinionTitle(opinion)}
|
||||
</h3>
|
||||
</div>
|
||||
{sanitizedHtml ? (
|
||||
<div
|
||||
className="prose prose-sm max-w-none font-serif leading-7 text-gray-900 [&_*]:font-serif [&_.case-page-number]:mx-1 [&_.case-page-number]:text-xs [&_.case-page-number]:text-gray-400 [&_a]:text-blue-600 [&_a]:underline [&_a:hover]:text-blue-700 [&_p]:my-3"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
|
||||
/>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap font-serif text-sm leading-7 text-gray-900 [&_p]:my-3">
|
||||
{opinion.text || "No opinion text returned."}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
|
|
@ -29,14 +30,15 @@ import {
|
|||
isModelAvailable,
|
||||
type ModelProvider,
|
||||
} from "@/app/lib/modelAvailability";
|
||||
import type { MikeDocument, MikeMessage } from "../shared/types";
|
||||
import type { Document, Message } from "../shared/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ChatInputHandle {
|
||||
addDoc: (doc: MikeDocument) => void;
|
||||
addDoc: (doc: Document) => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSubmit: (message: MikeMessage) => void;
|
||||
onSubmit: (message: Message) => void;
|
||||
onCancel: () => void;
|
||||
isLoading: boolean;
|
||||
hideAddDocButton?: boolean;
|
||||
|
|
@ -60,7 +62,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
ref,
|
||||
) {
|
||||
const [value, setValue] = useState("");
|
||||
const [attachedDocs, setAttachedDocs] = useState<MikeDocument[]>([]);
|
||||
const [attachedDocs, setAttachedDocs] = useState<Document[]>([]);
|
||||
const [selectedWorkflow, setSelectedWorkflow] = useState<{
|
||||
id: string;
|
||||
title: string;
|
||||
|
|
@ -69,13 +71,15 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
const { profile } = useUserProfile();
|
||||
const apiKeys = profile?.apiKeys;
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const controlsRef = useRef<HTMLDivElement>(null);
|
||||
const [compactControls, setCompactControls] = useState(false);
|
||||
const [docSelectorOpen, setDocSelectorOpen] = useState(false);
|
||||
const [workflowModalOpen, setWorkflowModalOpen] = useState(false);
|
||||
const [apiKeyModalProvider, setApiKeyModalProvider] =
|
||||
useState<ModelProvider | null>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
addDoc: (doc: MikeDocument) => {
|
||||
addDoc: (doc: Document) => {
|
||||
setAttachedDocs((prev) => {
|
||||
if (prev.some((d) => d.id === doc.id)) return prev;
|
||||
return [...prev, doc];
|
||||
|
|
@ -83,7 +87,17 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
},
|
||||
}));
|
||||
|
||||
const handleAddDocFromProject = useCallback((doc: MikeDocument) => {
|
||||
useEffect(() => {
|
||||
const el = controlsRef.current;
|
||||
if (!el) return;
|
||||
const update = () => setCompactControls(el.offsetWidth < 430);
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const handleAddDocFromProject = useCallback((doc: Document) => {
|
||||
setAttachedDocs((prev) => {
|
||||
if (prev.some((d) => d.id === doc.id)) return prev;
|
||||
return [...prev, doc];
|
||||
|
|
@ -91,7 +105,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
}, []);
|
||||
|
||||
const handleAddDocsFromSelector = useCallback(
|
||||
(selectedDocs: MikeDocument[]) => {
|
||||
(selectedDocs: Document[]) => {
|
||||
setAttachedDocs((prev) => {
|
||||
const existing = new Set(prev.map((d) => d.id));
|
||||
return [
|
||||
|
|
@ -157,7 +171,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
return (
|
||||
<>
|
||||
<div className="w-full">
|
||||
<div className="border border-gray-300 rounded-[16px] md:rounded-[20px] bg-white">
|
||||
<div className="rounded-[18px] border border-white/65 bg-white/60 shadow-[0_4px_10px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.85),inset_0_-6px_14px_rgba(255,255,255,0.18)] backdrop-blur-2xl md:rounded-[22px]">
|
||||
{/* Attached chips */}
|
||||
{(selectedWorkflow || attachedDocs.length > 0) && (
|
||||
<div className="flex flex-wrap gap-1.5 px-2 pt-2">
|
||||
|
|
@ -184,12 +198,12 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
return (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="inline-flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-full text-xs text-white shadow border border-white/20 bg-black backdrop-blur-sm"
|
||||
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-400" />
|
||||
<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-400" />
|
||||
<File className="h-2.5 w-2.5 shrink-0 text-blue-500" />
|
||||
)}
|
||||
<span className="max-w-[140px] truncate">
|
||||
{doc.filename}
|
||||
|
|
@ -203,7 +217,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
),
|
||||
)
|
||||
}
|
||||
className="rounded-full p-0.5 ml-0.5 text-white/60 hover:text-white hover:bg-white/20 transition-colors"
|
||||
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>
|
||||
|
|
@ -227,7 +241,10 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between md:p-2.5 p-2">
|
||||
<div
|
||||
ref={controlsRef}
|
||||
className="flex items-center justify-between md:p-2.5 p-2"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{!hideAddDocButton && (
|
||||
<AddDocButton
|
||||
|
|
@ -236,6 +253,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
selectedDocIds={attachedDocs.map(
|
||||
(d) => d.id,
|
||||
)}
|
||||
hideLabel={compactControls}
|
||||
/>
|
||||
)}
|
||||
{!hideWorkflowButton && (
|
||||
|
|
@ -243,14 +261,25 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
type="button"
|
||||
onClick={() => setWorkflowModalOpen(true)}
|
||||
aria-label="Open workflows"
|
||||
className={`flex items-center gap-1.5 rounded-lg px-2 h-8 text-sm transition-colors ${selectedWorkflow ? "text-blue-600 hover:bg-blue-50" : "text-gray-400 hover:bg-gray-100 hover:text-gray-700"}`}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-lg px-2 h-8 text-sm transition-colors",
|
||||
selectedWorkflow
|
||||
? "text-blue-600 hover:bg-white/55"
|
||||
: "text-gray-400 hover:bg-white/55 hover:text-gray-700",
|
||||
)}
|
||||
>
|
||||
{selectedWorkflow ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Library className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
<span
|
||||
className={
|
||||
compactControls
|
||||
? "hidden"
|
||||
: "hidden sm:inline"
|
||||
}
|
||||
>
|
||||
Workflows
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -260,7 +289,10 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
type="button"
|
||||
onClick={onProjectsClick}
|
||||
aria-label="Open projects"
|
||||
className="flex items-center gap-1.5 rounded-lg px-2 h-8 text-sm text-gray-400 hover:bg-gray-100 hover:text-gray-700 transition-colors"
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-lg px-2 h-8 text-sm text-gray-400 hover:text-gray-700 transition-colors",
|
||||
"hover:bg-white/55",
|
||||
)}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">
|
||||
|
|
@ -278,7 +310,10 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="relative bg-gradient-to-b from-neutral-700 to-black text-white rounded-[10px] h-8 w-8 flex items-center justify-center cursor-pointer disabled:cursor-default disabled:from-neutral-600 disabled:to-black backdrop-blur-xl border border-white/30 active:enabled:scale-95 transition-all duration-150"
|
||||
className={cn(
|
||||
"relative bg-gradient-to-b from-neutral-700 to-black text-white rounded-[10px] h-8 w-8 flex items-center justify-center cursor-pointer disabled:cursor-default disabled:from-neutral-600 disabled:to-black backdrop-blur-xl border border-white/30 active:enabled:scale-95 transition-all duration-150",
|
||||
"shadow-[0_5px_14px_rgba(15,23,42,0.18),inset_0_1px_0_rgba(255,255,255,0.24)]",
|
||||
)}
|
||||
onClick={handleActionClick}
|
||||
disabled={!isLoading && !value.trim()}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useState, useRef, useEffect } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import { UserMessage } from "./UserMessage";
|
||||
import { AssistantMessage } from "./AssistantMessage";
|
||||
|
|
@ -11,21 +12,35 @@ import {
|
|||
} from "./AssistantSidePanel";
|
||||
import { AssistantWorkflowModal } from "./AssistantWorkflowModal";
|
||||
import type {
|
||||
MikeCitationAnnotation,
|
||||
MikeEditAnnotation,
|
||||
MikeMessage,
|
||||
AssistantEvent,
|
||||
CitationAnnotation,
|
||||
EditAnnotation,
|
||||
Message,
|
||||
} from "../shared/types";
|
||||
import { useSidebar } from "@/app/contexts/SidebarContext";
|
||||
import { invalidateDocxBytes } from "@/app/hooks/useFetchDocxBytes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
messages: MikeMessage[];
|
||||
chatId?: string | null;
|
||||
messages: Message[];
|
||||
isResponseLoading: boolean;
|
||||
handleChat: (message: MikeMessage) => Promise<string | null>;
|
||||
handleChat: (message: Message) => Promise<string | null>;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
const ASSISTANT_PANEL_TRANSITION_MS = 500;
|
||||
const MOBILE_BREAKPOINT_PX = 768;
|
||||
|
||||
function isSmallScreen() {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
window.innerWidth < MOBILE_BREAKPOINT_PX
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
chatId,
|
||||
messages,
|
||||
isResponseLoading,
|
||||
handleChat,
|
||||
|
|
@ -49,38 +64,86 @@ export function ChatView({
|
|||
() => new Set(),
|
||||
);
|
||||
const { setSidebarOpen } = useSidebar();
|
||||
|
||||
const panelCloseTimerRef = useRef<number | null>(null);
|
||||
|
||||
const showPanel = useCallback(() => {
|
||||
if (panelCloseTimerRef.current !== null) {
|
||||
window.clearTimeout(panelCloseTimerRef.current);
|
||||
panelCloseTimerRef.current = null;
|
||||
}
|
||||
flushSync(() => {
|
||||
setSidebarOpen(false);
|
||||
});
|
||||
|
||||
if (panelMounted) {
|
||||
setPanelVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setPanelVisible(false);
|
||||
setPanelMounted(true);
|
||||
setSidebarOpen(false);
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => setPanelVisible(true)),
|
||||
);
|
||||
}, [panelMounted, setSidebarOpen]);
|
||||
|
||||
const restoreSidebarAfterPanelClose = useCallback(() => {
|
||||
if (!isSmallScreen()) setSidebarOpen(true);
|
||||
}, [setSidebarOpen]);
|
||||
|
||||
const closeAllTabs = useCallback(() => {
|
||||
setPanelVisible(false);
|
||||
setTimeout(() => {
|
||||
setTabs([]);
|
||||
setActiveTabId(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (panelCloseTimerRef.current !== null) {
|
||||
window.clearTimeout(panelCloseTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const hidePanel = useCallback(
|
||||
(afterHidden: () => void) => {
|
||||
if (panelCloseTimerRef.current !== null) {
|
||||
window.clearTimeout(panelCloseTimerRef.current);
|
||||
}
|
||||
setPanelVisible(false);
|
||||
panelCloseTimerRef.current = window.setTimeout(() => {
|
||||
panelCloseTimerRef.current = null;
|
||||
afterHidden();
|
||||
}, ASSISTANT_PANEL_TRANSITION_MS);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const unmountPanel = useCallback(
|
||||
(afterUnmount?: () => void) => {
|
||||
setPanelMounted(false);
|
||||
setSidebarOpen(true);
|
||||
}, 300);
|
||||
}, [setSidebarOpen]);
|
||||
restoreSidebarAfterPanelClose();
|
||||
afterUnmount?.();
|
||||
},
|
||||
[restoreSidebarAfterPanelClose],
|
||||
);
|
||||
|
||||
const closeAllTabs = useCallback(() => {
|
||||
hidePanel(() =>
|
||||
unmountPanel(() => {
|
||||
setTabs([]);
|
||||
setActiveTabId(null);
|
||||
}),
|
||||
);
|
||||
}, [hidePanel, unmountPanel]);
|
||||
|
||||
const closeTab = useCallback(
|
||||
(id: string) => {
|
||||
setTabs((prev) => {
|
||||
const next = prev.filter((t) => t.id !== id);
|
||||
if (next.length === 0) {
|
||||
setPanelVisible(false);
|
||||
setTimeout(() => {
|
||||
setActiveTabId(null);
|
||||
setPanelMounted(false);
|
||||
setSidebarOpen(true);
|
||||
}, 300);
|
||||
return next;
|
||||
hidePanel(() =>
|
||||
unmountPanel(() => {
|
||||
setActiveTabId(null);
|
||||
setTabs([]);
|
||||
}),
|
||||
);
|
||||
return prev;
|
||||
}
|
||||
if (activeTabId === id) {
|
||||
const idx = prev.findIndex((t) => t.id === id);
|
||||
|
|
@ -90,7 +153,7 @@ export function ChatView({
|
|||
return next;
|
||||
});
|
||||
},
|
||||
[activeTabId, setSidebarOpen],
|
||||
[activeTabId, hidePanel, unmountPanel],
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -104,18 +167,23 @@ export function ChatView({
|
|||
const upsertTab = useCallback(
|
||||
(tab: AssistantSidePanelTab) => {
|
||||
setTabs((prev) => {
|
||||
const idx = prev.findIndex(
|
||||
(t) => t.documentId === tab.documentId,
|
||||
const idx = prev.findIndex((t) =>
|
||||
tab.kind === "case"
|
||||
? t.kind === "case" && t.id === tab.id
|
||||
: t.kind !== "case" && t.documentId === tab.documentId,
|
||||
);
|
||||
if (idx >= 0) {
|
||||
const existing = prev[idx];
|
||||
const copy = prev.slice();
|
||||
copy[idx] = {
|
||||
...tab,
|
||||
id: existing.id,
|
||||
warning: existing.warning,
|
||||
initialScrollTop: existing.initialScrollTop,
|
||||
};
|
||||
copy[idx] =
|
||||
tab.kind === "case" || existing.kind === "case"
|
||||
? tab
|
||||
: {
|
||||
...tab,
|
||||
id: existing.id,
|
||||
warning: existing.warning,
|
||||
initialScrollTop: existing.initialScrollTop,
|
||||
};
|
||||
return copy;
|
||||
}
|
||||
return [...prev, tab];
|
||||
|
|
@ -131,7 +199,38 @@ export function ChatView({
|
|||
* AssistantMessage when the user clicks a numbered citation pill.
|
||||
*/
|
||||
const openCitation = useCallback(
|
||||
(citation: MikeCitationAnnotation) => {
|
||||
(citation: CitationAnnotation, options?: { showQuotes?: boolean }) => {
|
||||
const showQuotes = options?.showQuotes ?? true;
|
||||
if (citation.kind === "case") {
|
||||
if (!chatId) return;
|
||||
upsertTab({
|
||||
kind: "case",
|
||||
id: `case:${citation.cluster_id}`,
|
||||
chatId,
|
||||
clusterId: citation.cluster_id,
|
||||
citationRef: citation.ref,
|
||||
caseName: citation.case_name ?? null,
|
||||
citation: citation.citation ?? null,
|
||||
url: citation.url ?? null,
|
||||
dateFiled: citation.dateFiled ?? null,
|
||||
pdfUrl: citation.pdfUrl ?? null,
|
||||
judges: citation.judges ?? null,
|
||||
quotes: showQuotes ? citation.quotes : undefined,
|
||||
opinions: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!showQuotes) {
|
||||
upsertTab({
|
||||
kind: "document",
|
||||
id: citation.document_id,
|
||||
documentId: citation.document_id,
|
||||
filename: citation.filename,
|
||||
versionId: citation.version_id ?? null,
|
||||
versionNumber: citation.version_number ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
upsertTab({
|
||||
kind: "citation",
|
||||
id: citation.document_id,
|
||||
|
|
@ -142,7 +241,30 @@ export function ChatView({
|
|||
citation,
|
||||
});
|
||||
},
|
||||
[upsertTab],
|
||||
[chatId, upsertTab],
|
||||
);
|
||||
|
||||
const openCase = useCallback(
|
||||
(citation: Extract<AssistantEvent, { type: "case_citation" }>) => {
|
||||
if (!citation.cluster_id) return;
|
||||
if (!chatId) return;
|
||||
upsertTab({
|
||||
kind: "case",
|
||||
id: `case:${citation.cluster_id}`,
|
||||
chatId,
|
||||
clusterId: citation.cluster_id,
|
||||
citationRef: undefined,
|
||||
caseName: citation.case_name,
|
||||
citation: citation.citation,
|
||||
url: citation.url,
|
||||
dateFiled: citation.dateFiled ?? null,
|
||||
pdfUrl: citation.pdfUrl ?? null,
|
||||
judges: citation.judges ?? null,
|
||||
quotes: undefined,
|
||||
opinions: citation.case?.opinions,
|
||||
});
|
||||
},
|
||||
[chatId, upsertTab],
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -150,7 +272,7 @@ export function ChatView({
|
|||
* AssistantMessage when the user clicks an EditCard's View button.
|
||||
*/
|
||||
const openEditor = useCallback(
|
||||
(ann: MikeEditAnnotation, filename: string) => {
|
||||
(ann: EditAnnotation, filename: string) => {
|
||||
upsertTab({
|
||||
kind: "edit",
|
||||
id: ann.document_id,
|
||||
|
|
@ -260,15 +382,18 @@ export function ChatView({
|
|||
[],
|
||||
);
|
||||
|
||||
|
||||
const patchTab = useCallback(
|
||||
(
|
||||
tabId: string,
|
||||
patch: Partial<Pick<AssistantSidePanelTab, "warning" | "initialScrollTop">>,
|
||||
patch: {
|
||||
warning?: string | null;
|
||||
initialScrollTop?: number | null;
|
||||
},
|
||||
) => {
|
||||
setTabs((prev) => {
|
||||
const idx = prev.findIndex((t) => t.id === tabId);
|
||||
if (idx < 0) return prev;
|
||||
if (prev[idx].kind === "case") return prev;
|
||||
const copy = prev.slice();
|
||||
copy[idx] = { ...copy[idx], ...patch };
|
||||
return copy;
|
||||
|
|
@ -287,7 +412,7 @@ export function ChatView({
|
|||
// Surface the warning on every tab tied to this document.
|
||||
setTabs((prev) =>
|
||||
prev.map((t) =>
|
||||
t.documentId === args.documentId
|
||||
t.kind !== "case" && t.documentId === args.documentId
|
||||
? { ...t, warning: args.message }
|
||||
: t,
|
||||
),
|
||||
|
|
@ -328,8 +453,15 @@ export function ChatView({
|
|||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const latestUserMessageRef = useRef<HTMLDivElement>(null);
|
||||
const chatInputRef = useRef<HTMLDivElement>(null);
|
||||
const hasScrolledRef = useRef(false);
|
||||
const [messagesVisible, setMessagesVisible] = useState(false);
|
||||
// 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
|
||||
// remount. Existing chats mount with messages === [] and fetch async, so
|
||||
// they still start hidden and reveal once loaded.
|
||||
const hasScrolledRef = useRef(messages.length > 0);
|
||||
const [messagesVisible, setMessagesVisible] = useState(
|
||||
() => messages.length > 0,
|
||||
);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
const [inputHeight, setInputHeight] = useState(0);
|
||||
const [minHeight, setMinHeight] = useState("0px");
|
||||
|
|
@ -446,7 +578,7 @@ export function ChatView({
|
|||
return (
|
||||
<div className="h-full w-full flex relative">
|
||||
{/* Chat column */}
|
||||
<div className="flex flex-col h-full flex-1 relative">
|
||||
<div className="flex min-w-0 flex-col h-full flex-1 relative">
|
||||
{/* Scrollable messages */}
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
|
|
@ -507,13 +639,28 @@ export function ChatView({
|
|||
}
|
||||
isError={!!(msg as any).error}
|
||||
errorMessage={
|
||||
typeof (msg as any).error ===
|
||||
"string"
|
||||
typeof (msg as any)
|
||||
.error === "string"
|
||||
? (msg as any).error
|
||||
: undefined
|
||||
}
|
||||
annotations={msg.annotations}
|
||||
onCitationClick={openCitation}
|
||||
citationStatus={
|
||||
msg.citationStatus
|
||||
}
|
||||
onCitationClick={(citation) =>
|
||||
openCitation(citation)
|
||||
}
|
||||
onOpenCitationSource={(
|
||||
citation,
|
||||
) =>
|
||||
openCitation(citation, {
|
||||
showQuotes: false,
|
||||
})
|
||||
}
|
||||
onCaseClick={(citation) =>
|
||||
openCase(citation)
|
||||
}
|
||||
minHeight={
|
||||
i === lastAssistantIndex
|
||||
? minHeight
|
||||
|
|
@ -561,7 +708,10 @@ export function ChatView({
|
|||
>
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
className="p-2 rounded-full bg-white/70 backdrop-blur-xs shadow-lg cursor-pointer border border-gray-300"
|
||||
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)]",
|
||||
)}
|
||||
>
|
||||
<ArrowDown className="h-6 w-6 text-gray-500" />
|
||||
</button>
|
||||
|
|
@ -573,8 +723,19 @@ export function ChatView({
|
|||
ref={chatInputRef}
|
||||
className="absolute bottom-0 left-0 right-0 w-full z-30"
|
||||
>
|
||||
<div className="w-full max-w-4xl mx-auto px-4 md:px-6">
|
||||
<div className="w-full rounded-t-[20px] bg-white">
|
||||
<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",
|
||||
)}
|
||||
>
|
||||
<ChatInput
|
||||
onSubmit={handleChat}
|
||||
onCancel={cancel}
|
||||
|
|
@ -600,7 +761,7 @@ export function ChatView({
|
|||
|
||||
{panelMounted && (
|
||||
<div
|
||||
className={`fixed md:relative inset-0 md:inset-auto md:h-full md:flex-shrink-0 z-40 md:z-auto transition-transform duration-300 ease-in-out ${panelVisible ? "translate-x-0" : "translate-x-full"}`}
|
||||
className={`fixed inset-0 z-40 flex justify-center p-3 transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] md:relative md:inset-auto md:z-auto md:block md:h-full md:min-w-0 md:flex-shrink-0 md:p-0 ${panelVisible ? "translate-x-0" : "translate-x-full"}`}
|
||||
>
|
||||
<AssistantSidePanel
|
||||
tabs={tabs}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import type { MikeEditAnnotation } from "../shared/types";
|
||||
import type { EditAnnotation } from "../shared/types";
|
||||
|
||||
function normalizeText(s: string) {
|
||||
return s.replace(/\s+/g, " ").trim();
|
||||
|
|
@ -19,13 +19,6 @@ function findMatch(
|
|||
const byId = container.querySelector(
|
||||
`${tag}[data-w-id="${opts.w_id}"]`,
|
||||
) as HTMLElement | null;
|
||||
console.log("[EditCard] findMatch by w_id", {
|
||||
tag,
|
||||
w_id: opts.w_id,
|
||||
found: !!byId,
|
||||
totalTagged: container.querySelectorAll(`${tag}[data-w-id]`).length,
|
||||
totalAny: container.querySelectorAll(tag).length,
|
||||
});
|
||||
if (byId) return byId;
|
||||
}
|
||||
const text = opts.text ?? "";
|
||||
|
|
@ -42,12 +35,6 @@ function findMatch(
|
|||
normalizeText(el.textContent ?? "").includes(target),
|
||||
) ??
|
||||
null;
|
||||
console.log("[EditCard] findMatch by text", {
|
||||
tag,
|
||||
target,
|
||||
found: !!byText,
|
||||
candidateCount: candidates.length,
|
||||
});
|
||||
return byText;
|
||||
}
|
||||
|
||||
|
|
@ -63,7 +50,7 @@ function findMatch(
|
|||
* so if the backend call later fails we can restore the original look.
|
||||
*/
|
||||
export function applyOptimisticResolution(
|
||||
annotation: MikeEditAnnotation,
|
||||
annotation: EditAnnotation,
|
||||
verb: "accept" | "reject",
|
||||
): () => void {
|
||||
const reverts: (() => void)[] = [];
|
||||
|
|
@ -117,13 +104,6 @@ export function applyOptimisticResolution(
|
|||
const scrolls = document.querySelectorAll(
|
||||
`[data-document-id="${CSS.escape(annotation.document_id)}"]`,
|
||||
);
|
||||
console.log("[EditCard] optimistic scrolls found:", scrolls.length, {
|
||||
document_id: annotation.document_id,
|
||||
ins_w_id: annotation.ins_w_id,
|
||||
del_w_id: annotation.del_w_id,
|
||||
inserted_text: annotation.inserted_text?.slice(0, 40),
|
||||
deleted_text: annotation.deleted_text?.slice(0, 40),
|
||||
});
|
||||
scrolls.forEach((scroll) => {
|
||||
const container = scroll.querySelector(".docx-view-container");
|
||||
if (!container) return;
|
||||
|
|
@ -150,7 +130,7 @@ export function applyOptimisticResolution(
|
|||
}
|
||||
|
||||
interface Props {
|
||||
annotation: MikeEditAnnotation;
|
||||
annotation: EditAnnotation;
|
||||
/**
|
||||
* External override for this edit's status. When set, takes
|
||||
* precedence over the annotation's DB status and the card's own
|
||||
|
|
@ -164,7 +144,7 @@ interface Props {
|
|||
* Accept/Reject buttons disable so the user can't race resolutions.
|
||||
*/
|
||||
isReloading?: boolean;
|
||||
onViewClick?: (ann: MikeEditAnnotation) => void;
|
||||
onViewClick?: (ann: EditAnnotation) => void;
|
||||
/**
|
||||
* Fires immediately when the user clicks Accept or Reject, before the
|
||||
* backend round-trip. Parents use this to show an in-progress spinner
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ import { useUserProfile } from "@/contexts/UserProfileContext";
|
|||
import { MikeIcon } from "@/components/chat/mike-icon";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import { SelectAssistantProjectModal } from "./SelectAssistantProjectModal";
|
||||
import type { MikeMessage } from "../shared/types";
|
||||
import type { Message } from "../shared/types";
|
||||
|
||||
interface InitialViewProps {
|
||||
onSubmit: (message: MikeMessage) => void;
|
||||
onSubmit: (message: Message) => void;
|
||||
}
|
||||
|
||||
const ICON_SIZE = 35;
|
||||
const GAP = 16; // gap-4 = 1rem = 16px
|
||||
const ICON_SIZE = 30;
|
||||
const GAP = 12; // gap-4 = 1rem = 16px
|
||||
|
||||
export function InitialView({ onSubmit }: InitialViewProps) {
|
||||
const { user } = useAuth();
|
||||
|
|
@ -46,7 +46,7 @@ export function InitialView({ onSubmit }: InitialViewProps) {
|
|||
<div className="flex-col items-center w-full max-w-4xl relative px-0 xl:px-8">
|
||||
<div className="mb-10 relative flex items-center justify-center">
|
||||
<div
|
||||
className="absolute h-[35px]"
|
||||
className="absolute h-[30px] w-[30px] top-[-14px]"
|
||||
style={{
|
||||
left: "50%",
|
||||
transform: loaded
|
||||
|
|
|
|||
|
|
@ -25,7 +25,18 @@ export const MODELS: ModelOption[] = [
|
|||
{ id: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro", group: "Google" },
|
||||
{ id: "gemini-3-flash-preview", label: "Gemini 3 Flash", group: "Google" },
|
||||
{ id: "gpt-5.5", label: "GPT-5.5", group: "OpenAI" },
|
||||
{ id: "gpt-5.4-mini", label: "GPT-5.4 Mini", group: "OpenAI" },
|
||||
{ id: "gpt-5.4", label: "GPT-5.4", group: "OpenAI" },
|
||||
];
|
||||
|
||||
export const SETTINGS_MODELS: ModelOption[] = [
|
||||
...MODELS,
|
||||
{ id: "claude-haiku-4-5", label: "Claude Haiku 4.5", group: "Anthropic" },
|
||||
{
|
||||
id: "gemini-3.1-flash-lite-preview",
|
||||
label: "Gemini 3.1 Flash Lite",
|
||||
group: "Google",
|
||||
},
|
||||
{ id: "gpt-5.4-lite", label: "GPT-5.4 Lite", group: "OpenAI" },
|
||||
];
|
||||
|
||||
export const DEFAULT_MODEL_ID = "gemini-3-flash-preview";
|
||||
|
|
@ -69,7 +80,7 @@ export function ModelToggle({ value, onChange, apiKeys }: Props) {
|
|||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56 z-50" side="top" align="start">
|
||||
<DropdownMenuContent className="w-56 z-50" side="top" align="end">
|
||||
{GROUP_ORDER.map((group, gi) => {
|
||||
const items = MODELS.filter((m) => m.group === group);
|
||||
if (items.length === 0) return null;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, Loader2 } from "lucide-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";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
|
@ -40,53 +39,23 @@ export function SelectAssistantProjectModal({ open, onClose }: Props) {
|
|||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs">
|
||||
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
<span>Assistant</span>
|
||||
<span>›</span>
|
||||
<span>Start Chat in a Project</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ProjectPicker
|
||||
projects={projects}
|
||||
loading={loading}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-gray-100 px-4 py-3 flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
disabled={!selectedId || creating}
|
||||
className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40"
|
||||
>
|
||||
{creating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
breadcrumbs={["Assistant", "Start Chat in a Project"]}
|
||||
primaryAction={{
|
||||
label: creating ? "Creating…" : "Continue",
|
||||
onClick: handleContinue,
|
||||
disabled: !selectedId || creating,
|
||||
}}
|
||||
>
|
||||
<ProjectPicker
|
||||
projects={projects}
|
||||
loading={loading}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
769
frontend/src/app/components/projects/DocumentSidePanel.tsx
Normal file
769
frontend/src/app/components/projects/DocumentSidePanel.tsx
Normal file
|
|
@ -0,0 +1,769 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
Download,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
|
||||
import { DocView } from "@/app/components/shared/DocView";
|
||||
import { DocFileIcon } from "@/app/components/shared/FileDirectory";
|
||||
import { WarningPopup } from "@/app/components/shared/WarningPopup";
|
||||
import type { Document } from "@/app/components/shared/types";
|
||||
import type { DocumentVersion } from "@/app/lib/mikeApi";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatBytes, formatDate } from "./ProjectPageParts";
|
||||
|
||||
const MIN_DOC_COLUMN_WIDTH = 420;
|
||||
const DEFAULT_DOC_COLUMN_WIDTH = 620;
|
||||
const MIN_DATA_COLUMN_WIDTH = 280;
|
||||
const DEFAULT_DATA_COLUMN_WIDTH = 340;
|
||||
const RESIZER_WIDTH = 6;
|
||||
const MAX_PANEL_WIDTH = 1180;
|
||||
|
||||
interface DocumentSidePanelProps {
|
||||
doc: Document | null;
|
||||
versionId?: string | null;
|
||||
currentVersionId?: string | null;
|
||||
versions: DocumentVersion[];
|
||||
versionsLoading: boolean;
|
||||
onClose: () => void;
|
||||
onLoadVersions: (docId: string) => Promise<void> | void;
|
||||
onSelectVersion: (versionId: string, label: string) => void;
|
||||
onDownloadDocument: (docId: string) => Promise<void> | void;
|
||||
onDownloadVersion: (
|
||||
docId: string,
|
||||
versionId: string,
|
||||
filename: string,
|
||||
) => Promise<void> | void;
|
||||
onRenameVersion: (
|
||||
docId: string,
|
||||
versionId: string,
|
||||
filename: string,
|
||||
) => Promise<void> | void;
|
||||
onDeleteVersion: (
|
||||
docId: string,
|
||||
versionId: string,
|
||||
) => Promise<void> | void;
|
||||
onUploadNewVersion: (
|
||||
doc: Document,
|
||||
file: File,
|
||||
filename: string,
|
||||
) => Promise<void>;
|
||||
onDelete: (doc: Document) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function DocumentSidePanel({
|
||||
doc,
|
||||
versionId,
|
||||
currentVersionId,
|
||||
versions,
|
||||
versionsLoading,
|
||||
onClose,
|
||||
onLoadVersions,
|
||||
onSelectVersion,
|
||||
onDownloadDocument,
|
||||
onDownloadVersion,
|
||||
onRenameVersion,
|
||||
onDeleteVersion,
|
||||
onUploadNewVersion,
|
||||
onDelete,
|
||||
}: DocumentSidePanelProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameDraft, setNameDraft] = useState("");
|
||||
const [savingName, setSavingName] = useState(false);
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const [extensionWarningOpen, setExtensionWarningOpen] = useState(false);
|
||||
const [deletingVersion, setDeletingVersion] = useState(false);
|
||||
const [deletingDocument, setDeletingDocument] = useState(false);
|
||||
const [confirmDeleteDocumentOpen, setConfirmDeleteDocumentOpen] =
|
||||
useState(false);
|
||||
const [deleteDocumentStatus, setDeleteDocumentStatus] = useState<
|
||||
"idle" | "deleting" | "deleted"
|
||||
>("idle");
|
||||
const [dataColumnWidth, setDataColumnWidth] = useState(
|
||||
DEFAULT_DATA_COLUMN_WIDTH,
|
||||
);
|
||||
const [panelWidth, setPanelWidth] = useState(
|
||||
DEFAULT_DOC_COLUMN_WIDTH + RESIZER_WIDTH + DEFAULT_DATA_COLUMN_WIDTH,
|
||||
);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dragStartX = useRef(0);
|
||||
const dragStartDataWidth = useRef(DEFAULT_DATA_COLUMN_WIDTH);
|
||||
const dragStartPanelWidth = useRef(
|
||||
DEFAULT_DOC_COLUMN_WIDTH + RESIZER_WIDTH + DEFAULT_DATA_COLUMN_WIDTH,
|
||||
);
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
function handleWindowResize() {
|
||||
setPanelWidth((width) => clampPanelWidth(width, dataColumnWidth));
|
||||
}
|
||||
handleWindowResize();
|
||||
window.addEventListener("resize", handleWindowResize);
|
||||
return () => window.removeEventListener("resize", handleWindowResize);
|
||||
}, [dataColumnWidth, mounted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!doc) return;
|
||||
setUploadError(null);
|
||||
void onLoadVersions(doc.id);
|
||||
}, [doc?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditingName(false);
|
||||
setNameDraft("");
|
||||
setNameError(null);
|
||||
setExtensionWarningOpen(false);
|
||||
}, [doc?.id, versionId, currentVersionId]);
|
||||
|
||||
if (!mounted || !doc) return null;
|
||||
|
||||
const activeDoc = doc;
|
||||
const documentId = activeDoc.id;
|
||||
const accept = doc.file_type === "pdf" ? ".pdf" : ".docx,.doc";
|
||||
const orderedVersions = [...versions].reverse();
|
||||
const selectedVersion =
|
||||
versions.find((version) => version.id === versionId) ??
|
||||
versions.find((version) => version.id === currentVersionId) ??
|
||||
orderedVersions[0] ??
|
||||
null;
|
||||
const selectedVersionId = selectedVersion?.id ?? versionId ?? null;
|
||||
const selectedFilename =
|
||||
selectedVersion?.filename?.trim() || doc.filename;
|
||||
const selectedFileType =
|
||||
selectedVersion != null
|
||||
? fileTypeForVersion(selectedVersion, doc.file_type)
|
||||
: doc.file_type;
|
||||
const selectedSizeBytes =
|
||||
selectedVersion?.size_bytes === undefined
|
||||
? doc.size_bytes
|
||||
: selectedVersion.size_bytes;
|
||||
const selectedPageCount =
|
||||
selectedVersion?.page_count === undefined
|
||||
? doc.page_count
|
||||
: selectedVersion.page_count;
|
||||
const selectedVersionNumber =
|
||||
selectedVersion?.version_number ?? doc.active_version_number ?? null;
|
||||
const selectedUploadedAt = selectedVersion?.created_at ?? doc.created_at;
|
||||
const selectedExtension = filenameExtension(selectedFilename);
|
||||
|
||||
async function handleSaveName() {
|
||||
if (!selectedVersionId) return;
|
||||
const trimmed = nameDraft.trim();
|
||||
if (!trimmed) {
|
||||
setNameError("Name is required.");
|
||||
return;
|
||||
}
|
||||
if (hasExtensionChange(selectedFilename, trimmed)) {
|
||||
setExtensionWarningOpen(true);
|
||||
return;
|
||||
}
|
||||
if (trimmed === selectedFilename) {
|
||||
setEditingName(false);
|
||||
setNameError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingName(true);
|
||||
setNameError(null);
|
||||
try {
|
||||
await onRenameVersion(documentId, selectedVersionId, trimmed);
|
||||
setEditingName(false);
|
||||
} catch (err) {
|
||||
console.error("rename version failed", err);
|
||||
setNameError("Could not save name.");
|
||||
} finally {
|
||||
setSavingName(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0] ?? null;
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
if (!file || !doc) return;
|
||||
setUploadError(null);
|
||||
setUploading(true);
|
||||
try {
|
||||
await onUploadNewVersion(doc, file, file.name);
|
||||
} catch (err) {
|
||||
console.error("upload new version failed", err);
|
||||
setUploadError("Could not upload the new version.");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSelectedVersion() {
|
||||
if (!selectedVersionId) return;
|
||||
setDeletingVersion(true);
|
||||
try {
|
||||
await onDeleteVersion(documentId, selectedVersionId);
|
||||
} catch (err) {
|
||||
console.error("delete version failed", err);
|
||||
} finally {
|
||||
setDeletingVersion(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteDocument() {
|
||||
if (deleteDocumentStatus === "deleting") return;
|
||||
setDeleteDocumentStatus("deleting");
|
||||
setDeletingDocument(true);
|
||||
try {
|
||||
await onDelete(activeDoc);
|
||||
setDeleteDocumentStatus("deleted");
|
||||
window.setTimeout(() => {
|
||||
setConfirmDeleteDocumentOpen(false);
|
||||
setDeleteDocumentStatus("idle");
|
||||
onClose();
|
||||
}, 650);
|
||||
} catch (err) {
|
||||
console.error("delete document failed", err);
|
||||
setDeleteDocumentStatus("idle");
|
||||
} finally {
|
||||
setDeletingDocument(false);
|
||||
}
|
||||
}
|
||||
|
||||
function requestDeleteDocument() {
|
||||
if (versions.length > 1) {
|
||||
setDeleteDocumentStatus("idle");
|
||||
setConfirmDeleteDocumentOpen(true);
|
||||
return;
|
||||
}
|
||||
void handleDeleteDocument();
|
||||
}
|
||||
|
||||
function handleResizeMouseDown(e: React.MouseEvent<HTMLDivElement>) {
|
||||
e.preventDefault();
|
||||
dragStartX.current = e.clientX;
|
||||
dragStartDataWidth.current = dataColumnWidth;
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
const panelWidth =
|
||||
panelRef.current?.clientWidth ?? window.innerWidth;
|
||||
const maxDataWidth = Math.max(
|
||||
MIN_DATA_COLUMN_WIDTH,
|
||||
panelWidth - MIN_DOC_COLUMN_WIDTH - RESIZER_WIDTH,
|
||||
);
|
||||
const nextWidth =
|
||||
dragStartDataWidth.current + (dragStartX.current - event.clientX);
|
||||
setDataColumnWidth(
|
||||
Math.min(
|
||||
maxDataWidth,
|
||||
Math.max(MIN_DATA_COLUMN_WIDTH, nextWidth),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}
|
||||
|
||||
function handlePanelResizeMouseDown(e: React.MouseEvent<HTMLDivElement>) {
|
||||
e.preventDefault();
|
||||
dragStartX.current = e.clientX;
|
||||
dragStartPanelWidth.current = panelWidth;
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
const nextWidth =
|
||||
dragStartPanelWidth.current + (dragStartX.current - event.clientX);
|
||||
setPanelWidth(clampPanelWidth(nextWidth, dataColumnWidth));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={cn(
|
||||
"fixed z-[190] flex flex-col",
|
||||
"inset-y-3 right-3 rounded-2xl border border-white/70 bg-white/72 shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18),inset_1px_0_0_rgba(255,255,255,0.5)] backdrop-blur-2xl overflow-hidden",
|
||||
)}
|
||||
style={{ width: panelWidth }}
|
||||
>
|
||||
<div
|
||||
onMouseDown={handlePanelResizeMouseDown}
|
||||
className="absolute inset-y-0 left-0 z-20 w-1 cursor-col-resize bg-transparent transition-colors hover:bg-blue-400/60"
|
||||
title="Resize document view"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-11 shrink-0 items-center justify-between px-4",
|
||||
"border-b border-white/60 bg-white/35",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-gray-700">
|
||||
{selectedFilename}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-7 w-7 items-center justify-center text-gray-500 transition-colors hover:text-gray-900"
|
||||
title="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grid min-h-0 flex-1"
|
||||
style={{
|
||||
gridTemplateColumns: `minmax(${MIN_DOC_COLUMN_WIDTH}px, 1fr) ${RESIZER_WIDTH}px ${dataColumnWidth}px`,
|
||||
}}
|
||||
>
|
||||
<section
|
||||
className={cn(
|
||||
"flex min-h-0 min-w-0 pb-3 pl-3",
|
||||
"bg-white/20",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden",
|
||||
"rounded-xl border border-white/60 bg-white/55 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] backdrop-blur-xl",
|
||||
)}
|
||||
>
|
||||
<DocView
|
||||
key={selectedVersionId ?? "current"}
|
||||
doc={{
|
||||
document_id: doc.id,
|
||||
version_id: selectedVersionId,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
onMouseDown={handleResizeMouseDown}
|
||||
className={cn(
|
||||
"relative cursor-col-resize transition-colors",
|
||||
"bg-white/25 hover:bg-blue-400/60",
|
||||
)}
|
||||
title="Resize document panel"
|
||||
/>
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col",
|
||||
"bg-white/25",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 px-4 pb-3 pt-0",
|
||||
"border-b border-white/60",
|
||||
)}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<div className="mb-3 text-xs font-medium text-gray-900">
|
||||
Name
|
||||
</div>
|
||||
{editingName ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex min-h-6 items-center gap-2">
|
||||
<input
|
||||
value={nameDraft}
|
||||
onChange={(e) => {
|
||||
setNameDraft(e.target.value);
|
||||
setNameError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleSaveName();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setEditingName(false);
|
||||
setNameError(null);
|
||||
}
|
||||
}}
|
||||
className="h-6 min-w-0 flex-1 border-0 border-b border-gray-300 bg-transparent px-0 text-xs leading-6 text-gray-900 outline-none transition-colors focus:border-gray-500"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSaveName()}
|
||||
disabled={savingName}
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-white/65 hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title="Save name"
|
||||
>
|
||||
{savingName ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{nameError && (
|
||||
<div className="text-xs text-red-600">
|
||||
{nameError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-6 items-center gap-2">
|
||||
<div className="min-w-0 flex-1 truncate text-xs leading-6 text-gray-800">
|
||||
{selectedFilename}
|
||||
</div>
|
||||
{selectedVersionId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNameDraft(selectedFilename);
|
||||
setEditingName(true);
|
||||
setNameError(null);
|
||||
}}
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-white/65 hover:text-gray-900"
|
||||
title="Edit name"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-3 text-xs font-medium text-gray-900">
|
||||
Document Data
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<DataRow label="Type" value={selectedFileType ?? "—"} />
|
||||
<DataRow
|
||||
label="Size"
|
||||
value={
|
||||
selectedSizeBytes != null
|
||||
? formatBytes(selectedSizeBytes)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<DataRow
|
||||
label="Version"
|
||||
value={
|
||||
selectedVersionNumber != null
|
||||
? String(selectedVersionNumber)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<DataRow
|
||||
label="Uploaded"
|
||||
value={
|
||||
selectedUploadedAt
|
||||
? formatDate(selectedUploadedAt)
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
{selectedPageCount != null && (
|
||||
<DataRow
|
||||
label="Pages"
|
||||
value={String(selectedPageCount)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void handleDeleteSelectedVersion()
|
||||
}
|
||||
disabled={
|
||||
!selectedVersionId ||
|
||||
versions.length <= 1 ||
|
||||
deletingVersion
|
||||
}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/65 px-3 py-2 text-xs font-medium text-red-600 transition-colors hover:border-red-200 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40",
|
||||
)}
|
||||
>
|
||||
{deletingVersion ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Delete version
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
selectedVersionId
|
||||
? void onDownloadVersion(
|
||||
doc.id,
|
||||
selectedVersionId,
|
||||
selectedFilename,
|
||||
)
|
||||
: void onDownloadDocument(doc.id)
|
||||
}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/65 px-3 py-2 text-xs font-medium text-gray-700 transition-colors hover:border-gray-400 hover:bg-white hover:text-gray-900"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col px-4 pb-3 pt-0">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col overflow-visible rounded-xl",
|
||||
"border border-white/60 bg-white/35 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 py-2 text-xs font-medium text-gray-900",
|
||||
"border-b border-white/60",
|
||||
)}
|
||||
>
|
||||
Versions
|
||||
</div>
|
||||
<div className="-mx-2 min-h-0 flex-1 overflow-y-auto px-2 py-2">
|
||||
{versionsLoading && versions.length === 0 ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-gray-400">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Loading versions
|
||||
</div>
|
||||
) : orderedVersions.length === 0 ? (
|
||||
<div className="py-2 text-xs text-gray-400">
|
||||
No version history.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{orderedVersions.map((version) => {
|
||||
const title =
|
||||
versionTitleFor(version);
|
||||
const filename =
|
||||
versionFilenameFor(version);
|
||||
const selected =
|
||||
selectedVersionId === version.id;
|
||||
const fileType =
|
||||
fileTypeForVersion(
|
||||
version,
|
||||
doc.file_type,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={version.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSelectVersion(
|
||||
version.id,
|
||||
filename,
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"group -mx-2 flex w-[calc(100%+1rem)] items-center gap-2 rounded-lg px-2 py-2 text-left transition-colors",
|
||||
selected
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-white/55",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<DocFileIcon
|
||||
fileType={
|
||||
fileType
|
||||
}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 truncate text-xs font-medium text-gray-800">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="truncate pl-[22px] text-[11px] text-gray-400">
|
||||
{filename}
|
||||
</div>
|
||||
<div className="truncate pl-[22px] text-[11px] text-gray-400">
|
||||
{version.created_at
|
||||
? new Date(
|
||||
version.created_at,
|
||||
).toLocaleString()
|
||||
: "—"}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploadError && (
|
||||
<div className="mx-4 mb-2 flex items-center gap-2 rounded-lg 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>{uploadError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-between px-4 py-3",
|
||||
"border-t border-white/60 bg-white/25",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={requestDeleteDocument}
|
||||
disabled={deletingDocument}
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/35 px-3 text-xs font-medium text-red-600 transition-colors hover:border-red-200 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{deletingDocument ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/35 px-3 text-xs font-medium text-gray-800 transition-colors hover:border-gray-400 hover:bg-white/60 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Upload new version
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<WarningPopup
|
||||
open={extensionWarningOpen}
|
||||
onClose={() => setExtensionWarningOpen(false)}
|
||||
message={
|
||||
selectedExtension
|
||||
? `File extensions cannot be changed here. Keep ${selectedExtension} at the end of the name.`
|
||||
: "File extensions cannot be changed here."
|
||||
}
|
||||
/>
|
||||
<ConfirmPopup
|
||||
open={confirmDeleteDocumentOpen}
|
||||
title="Delete document?"
|
||||
message={`${selectedFilename} has ${versions.length} versions. Deleting this document will delete all of its versions.`}
|
||||
confirmLabel="Delete"
|
||||
confirmStatus={
|
||||
deleteDocumentStatus === "deleting"
|
||||
? "loading"
|
||||
: deleteDocumentStatus === "deleted"
|
||||
? "complete"
|
||||
: "idle"
|
||||
}
|
||||
cancelLabel="Cancel"
|
||||
onCancel={() => {
|
||||
if (deleteDocumentStatus === "deleting") return;
|
||||
setConfirmDeleteDocumentOpen(false);
|
||||
setDeleteDocumentStatus("idle");
|
||||
}}
|
||||
onConfirm={() => void handleDeleteDocument()}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function DataRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[112px_minmax(0,1fr)] gap-2 text-xs">
|
||||
<span className="text-gray-400">{label}</span>
|
||||
<span className="truncate text-gray-800">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function clampPanelWidth(width: number, dataColumnWidth: number) {
|
||||
const minWidth = MIN_DOC_COLUMN_WIDTH + RESIZER_WIDTH + dataColumnWidth;
|
||||
const maxWidth =
|
||||
typeof window === "undefined"
|
||||
? MAX_PANEL_WIDTH
|
||||
: Math.min(MAX_PANEL_WIDTH, window.innerWidth - 16);
|
||||
return Math.min(maxWidth, Math.max(minWidth, width));
|
||||
}
|
||||
|
||||
function versionTitleFor(version: DocumentVersion) {
|
||||
if (
|
||||
typeof version.version_number === "number" &&
|
||||
version.version_number >= 1
|
||||
) {
|
||||
return `Version ${version.version_number}`;
|
||||
}
|
||||
return "Version";
|
||||
}
|
||||
|
||||
function versionFilenameFor(version: DocumentVersion) {
|
||||
if (version.filename?.trim()) return version.filename.trim();
|
||||
return version.source === "upload" ? "Original" : "—";
|
||||
}
|
||||
|
||||
function fileTypeForVersion(
|
||||
version: DocumentVersion,
|
||||
fallback: string | null,
|
||||
) {
|
||||
const name = version.filename?.trim().toLowerCase() ?? "";
|
||||
if (name.endsWith(".pdf")) return "pdf";
|
||||
if (name.endsWith(".doc") || name.endsWith(".docx")) return "docx";
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function filenameExtension(filename: string) {
|
||||
const trimmed = filename.trim();
|
||||
const dotIndex = trimmed.lastIndexOf(".");
|
||||
if (dotIndex <= 0 || dotIndex === trimmed.length - 1) return null;
|
||||
return trimmed.slice(dotIndex);
|
||||
}
|
||||
|
||||
function hasExtensionChange(previous: string, next: string) {
|
||||
const previousExtension = filenameExtension(previous);
|
||||
if (previousExtension == null) return false;
|
||||
return (
|
||||
filenameExtension(next)?.toLowerCase() !==
|
||||
previousExtension.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { X, Users, Upload } from "lucide-react";
|
||||
import { Users, Upload } from "lucide-react";
|
||||
import {
|
||||
addDocumentToProject,
|
||||
createProject,
|
||||
|
|
@ -10,13 +10,14 @@ import {
|
|||
import { useDirectoryData } from "../shared/useDirectoryData";
|
||||
import { FileDirectory } from "../shared/FileDirectory";
|
||||
import { EmailPillInput } from "../shared/EmailPillInput";
|
||||
import type { MikeProject } from "../shared/types";
|
||||
import type { Project } from "../shared/types";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { Modal } from "../shared/Modal";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: (project: MikeProject) => void;
|
||||
onCreated: (project: Project) => void;
|
||||
}
|
||||
|
||||
export function NewProjectModal({ open, onClose, onCreated }: Props) {
|
||||
|
|
@ -31,6 +32,7 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
|
|||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { user } = useAuth();
|
||||
const ownEmail = user?.email?.trim().toLowerCase() ?? null;
|
||||
const formId = "new-project-modal-form";
|
||||
|
||||
const { loading: dirLoading, standaloneDocuments, projects: dirProjects } = useDirectoryData(open);
|
||||
|
||||
|
|
@ -86,129 +88,93 @@ export function NewProjectModal({ open, onClose, onCreated }: Props) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-101 flex items-center justify-center bg-black/20 backdrop-blur-xs">
|
||||
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 pt-5 pb-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
<span>Projects</span>
|
||||
<span>›</span>
|
||||
<span>New project</span>
|
||||
</div>
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<form
|
||||
id={formId}
|
||||
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
|
||||
/>
|
||||
|
||||
<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 className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
|
||||
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"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<Users className="h-3 w-3 text-gray-400" />
|
||||
Members{sharedEmails.length > 0 ? ` (${sharedEmails.length})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
|
||||
<div className="px-6 pt-3 pb-5 flex-1 overflow-y-auto">
|
||||
{/* Title */}
|
||||
<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
|
||||
{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…"
|
||||
/>
|
||||
|
||||
{/* CM Number */}
|
||||
<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"
|
||||
/>
|
||||
|
||||
{/* Attribute pills */}
|
||||
<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>
|
||||
|
||||
{/* Members panel */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Documents */}
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
Upload files{pendingFiles.length > 0 ? ` (${pendingFiles.length})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="rounded-lg px-4 py-2 text-sm text-gray-500 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim() || loading}
|
||||
className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{loading ? "Creating…" : "Create project"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
import { type Dispatch, type SetStateAction } from "react";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { RowActions } from "@/app/components/shared/RowActions";
|
||||
import type { MikeChat } from "@/app/components/shared/types";
|
||||
import { CHECK_W, formatDate, NAME_COL_W } from "./ProjectPageParts";
|
||||
import type { Chat } from "@/app/components/shared/types";
|
||||
import { formatDate, NAME_COL_W } from "./ProjectPageParts";
|
||||
|
||||
export function ProjectAssistantTab({
|
||||
chats,
|
||||
|
|
@ -24,8 +24,8 @@ export function ProjectAssistantTab({
|
|||
setRenamingChatId,
|
||||
setRenameChatValue,
|
||||
}: {
|
||||
chats: MikeChat[];
|
||||
filteredChats: MikeChat[];
|
||||
chats: Chat[];
|
||||
filteredChats: Chat[];
|
||||
selectedChatIds: string[];
|
||||
allChatsSelected: boolean;
|
||||
someChatsSelected: boolean;
|
||||
|
|
@ -34,19 +34,19 @@ export function ProjectAssistantTab({
|
|||
currentUserId?: string | null;
|
||||
onCreateChat: () => void;
|
||||
onOpenChat: (chatId: string) => void;
|
||||
onDeleteChat: (chat: MikeChat) => Promise<void> | void;
|
||||
onDeleteChat: (chat: Chat) => Promise<void> | void;
|
||||
onOwnerOnlyAction: (action: string) => void;
|
||||
submitChatRename: (chatId: string) => Promise<void> | void;
|
||||
setSelectedChatIds: Dispatch<SetStateAction<string[]>>;
|
||||
setRenamingChatId: Dispatch<SetStateAction<string | null>>;
|
||||
setRenameChatValue: Dispatch<SetStateAction<string>>;
|
||||
}) {
|
||||
const stickyCellBg = "bg-[#fcfcfd]";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}
|
||||
>
|
||||
<div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChatsSelected}
|
||||
|
|
@ -59,11 +59,7 @@ export function ProjectAssistantTab({
|
|||
}}
|
||||
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white pl-2 text-left`}
|
||||
>
|
||||
Chats
|
||||
<span>Chats</span>
|
||||
</div>
|
||||
<div className="ml-auto w-32 shrink-0 text-left">Created</div>
|
||||
<div className="w-8 shrink-0" />
|
||||
|
|
@ -94,54 +90,48 @@ export function ProjectAssistantTab({
|
|||
if (renamingChatId === chat.id) return;
|
||||
onOpenChat(chat.id);
|
||||
}}
|
||||
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-100 cursor-pointer transition-colors"
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${
|
||||
selectedChatIds.includes(chat.id)
|
||||
? "bg-gray-50"
|
||||
: "bg-white"
|
||||
} group-hover:bg-gray-50`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`sticky left-0 z-[60] ${NAME_COL_W} ${selectedChatIds.includes(chat.id) ? "bg-gray-50" : stickyCellBg} py-2 pl-4 pr-2 transition-colors group-hover:bg-gray-100`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedChatIds.includes(chat.id)}
|
||||
onChange={() =>
|
||||
setSelectedChatIds((prev) =>
|
||||
prev.includes(chat.id)
|
||||
? prev.filter((x) => x !== chat.id)
|
||||
: [...prev, chat.id],
|
||||
)
|
||||
}
|
||||
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`}
|
||||
>
|
||||
{renamingChatId === chat.id ? (
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<input
|
||||
autoFocus
|
||||
value={renameChatValue}
|
||||
onChange={(e) =>
|
||||
setRenameChatValue(e.target.value)
|
||||
type="checkbox"
|
||||
checked={selectedChatIds.includes(chat.id)}
|
||||
onChange={() =>
|
||||
setSelectedChatIds((prev) =>
|
||||
prev.includes(chat.id)
|
||||
? prev.filter((x) => x !== chat.id)
|
||||
: [...prev, chat.id],
|
||||
)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter")
|
||||
void submitChatRename(chat.id);
|
||||
if (e.key === "Escape")
|
||||
setRenamingChatId(null);
|
||||
}}
|
||||
onBlur={() => void submitChatRename(chat.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full text-sm text-gray-800 bg-transparent outline-none"
|
||||
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-gray-800 truncate block">
|
||||
{chat.title ?? "Untitled Chat"}
|
||||
</span>
|
||||
)}
|
||||
{renamingChatId === chat.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameChatValue}
|
||||
onChange={(e) =>
|
||||
setRenameChatValue(e.target.value)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter")
|
||||
void submitChatRename(chat.id);
|
||||
if (e.key === "Escape")
|
||||
setRenamingChatId(null);
|
||||
}}
|
||||
onBlur={() => void submitChatRename(chat.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="min-w-0 flex-1 text-sm text-gray-800 bg-transparent outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-gray-800">
|
||||
{chat.title ?? "Untitled Chat"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto w-32 shrink-0 text-sm text-gray-500 truncate">
|
||||
{formatDate(chat.created_at)}
|
||||
|
|
|
|||
|
|
@ -11,15 +11,18 @@ import {
|
|||
FolderPlus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { MikeDocument, MikeFolder } from "@/app/components/shared/types";
|
||||
import type {
|
||||
Document,
|
||||
Folder as ProjectFolder,
|
||||
} from "@/app/components/shared/types";
|
||||
import { VersionChip } from "@/app/components/shared/VersionChip";
|
||||
|
||||
interface Props {
|
||||
projectName?: string | null;
|
||||
documents: MikeDocument[];
|
||||
folders?: MikeFolder[];
|
||||
documents: Document[];
|
||||
folders?: ProjectFolder[];
|
||||
selectedDocId?: string | null;
|
||||
onDocClick: (doc: MikeDocument) => void;
|
||||
onDocClick: (doc: Document) => void;
|
||||
onCreateFolder?: (parentFolderId: string | null, name: string) => Promise<void>;
|
||||
onRenameFolder?: (folderId: string, name: string) => Promise<void>;
|
||||
onDeleteFolder?: (folderId: string) => Promise<void>;
|
||||
|
|
@ -131,7 +134,7 @@ export function ProjectExplorer({
|
|||
}
|
||||
|
||||
function wouldCreateCycle(movingId: string, targetId: string): boolean {
|
||||
let cur: MikeFolder | undefined = folders.find((f) => f.id === targetId);
|
||||
let cur: ProjectFolder | undefined = folders.find((f) => f.id === targetId);
|
||||
while (cur) {
|
||||
if (cur.id === movingId) return true;
|
||||
if (!cur.parent_folder_id) break;
|
||||
|
|
@ -299,8 +302,15 @@ export function ProjectExplorer({
|
|||
style={{ paddingLeft: basePadding }}
|
||||
>
|
||||
<DocIcon fileType={doc.file_type} />
|
||||
<span className="text-xs truncate">{doc.filename}</span>
|
||||
<VersionChip n={doc.latest_version_number} />
|
||||
<span className="text-xs truncate">
|
||||
{doc.filename}
|
||||
</span>
|
||||
<VersionChip
|
||||
n={
|
||||
doc.active_version_number ??
|
||||
doc.latest_version_number
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2,18 +2,20 @@
|
|||
|
||||
import { type CSSProperties, useState } from "react";
|
||||
import {
|
||||
Download,
|
||||
CornerDownRight,
|
||||
File,
|
||||
FileText,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
MessageSquare,
|
||||
Search,
|
||||
Table2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { HeaderSearchBtn } from "@/app/components/shared/HeaderSearchBtn";
|
||||
import { PageHeader } from "@/app/components/shared/PageHeader";
|
||||
import { RenameableTitle } from "@/app/components/shared/RenameableTitle";
|
||||
import type { MikeProject } from "@/app/components/shared/types";
|
||||
import type { MikeDocumentVersion } from "@/app/lib/mikeApi";
|
||||
import type { Project } from "@/app/components/shared/types";
|
||||
import type { DocumentVersion } from "@/app/lib/mikeApi";
|
||||
import { RowActions } from "@/app/components/shared/RowActions";
|
||||
|
||||
export type ProjectTab = "documents" | "assistant" | "reviews";
|
||||
|
||||
|
|
@ -25,32 +27,18 @@ export type ProjectContextMenu = {
|
|||
showFolderActions: boolean;
|
||||
};
|
||||
|
||||
export const CHECK_W = "w-8 shrink-0";
|
||||
export const NAME_COL_W = "w-[300px] shrink-0";
|
||||
export const NAME_COL_W = "w-[332px] shrink-0";
|
||||
export const DOC_NAME_COL_W =
|
||||
"w-[260px] sm:w-[300px] md:w-[360px] lg:w-[420px] xl:w-[500px] 2xl:w-[560px] shrink-0";
|
||||
"w-[292px] sm:w-[332px] md:w-[392px] lg:w-[452px] xl:w-[532px] 2xl:w-[592px] shrink-0";
|
||||
|
||||
const TREE_CONTROL_WIDTH_PX = 32;
|
||||
const TREE_NAME_PADDING_PX = 8;
|
||||
|
||||
function treeControlWidth(depth: number) {
|
||||
return TREE_CONTROL_WIDTH_PX * (Math.max(0, depth) + 1);
|
||||
}
|
||||
|
||||
export function treeControlCellStyle(depth: number): CSSProperties | undefined {
|
||||
if (depth <= 0) return undefined;
|
||||
const width = treeControlWidth(depth);
|
||||
return {
|
||||
justifyContent: "flex-start",
|
||||
minWidth: width,
|
||||
paddingLeft: TREE_NAME_PADDING_PX + depth * TREE_CONTROL_WIDTH_PX,
|
||||
width,
|
||||
};
|
||||
}
|
||||
const TREE_NAME_PADDING_PX = 16;
|
||||
|
||||
export function treeNameCellStyle(depth: number): CSSProperties | undefined {
|
||||
if (depth <= 0) return undefined;
|
||||
return { left: treeControlWidth(depth) };
|
||||
return {
|
||||
paddingLeft: TREE_NAME_PADDING_PX + depth * TREE_CONTROL_WIDTH_PX,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
|
|
@ -78,17 +66,24 @@ export function DocIcon({ fileType }: { fileType: string | null }) {
|
|||
export function DocVersionHistory({
|
||||
docId,
|
||||
filename,
|
||||
fileType,
|
||||
activeVersionNumber,
|
||||
currentVersionId,
|
||||
loading,
|
||||
versions,
|
||||
depth = 0,
|
||||
onDownloadVersion,
|
||||
onOpenVersion,
|
||||
onRenameVersion,
|
||||
onExtensionChangeBlocked,
|
||||
}: {
|
||||
docId: string;
|
||||
filename: string;
|
||||
fileType: string | null;
|
||||
activeVersionNumber: number | null;
|
||||
currentVersionId: string | null;
|
||||
loading: boolean;
|
||||
versions: MikeDocumentVersion[];
|
||||
versions: DocumentVersion[];
|
||||
depth?: number;
|
||||
onDownloadVersion: (
|
||||
docId: string,
|
||||
|
|
@ -98,8 +93,9 @@ export function DocVersionHistory({
|
|||
onOpenVersion?: (versionId: string, versionLabel: string) => void;
|
||||
onRenameVersion?: (
|
||||
versionId: string,
|
||||
displayName: string | null,
|
||||
filename: string | null,
|
||||
) => Promise<void> | void;
|
||||
onExtensionChangeBlocked?: (filename: string) => void;
|
||||
}) {
|
||||
const [editingVersionId, setEditingVersionId] = useState<string | null>(
|
||||
null,
|
||||
|
|
@ -108,40 +104,69 @@ export function DocVersionHistory({
|
|||
|
||||
const commit = async (versionId: string) => {
|
||||
const trimmed = editingValue.trim();
|
||||
const previousFilename = versions
|
||||
.find((version) => version.id === versionId)
|
||||
?.filename?.trim();
|
||||
if (
|
||||
previousFilename &&
|
||||
(trimmed.length === 0 ||
|
||||
hasFilenameExtensionChange(previousFilename, trimmed))
|
||||
) {
|
||||
onExtensionChangeBlocked?.(previousFilename);
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingVersionId(null);
|
||||
const next = trimmed.length > 0 ? trimmed : null;
|
||||
await onRenameVersion?.(versionId, next);
|
||||
};
|
||||
|
||||
if (loading && versions.length === 0) {
|
||||
const skeletonCount = Math.max(0, (activeVersionNumber ?? 1) - 1);
|
||||
return (
|
||||
<div className="flex items-center h-9 border-b border-gray-50 text-xs text-gray-500 bg-gray-50/60">
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} bg-gray-50/60 self-stretch`}
|
||||
style={treeControlCellStyle(depth)}
|
||||
/>
|
||||
<div
|
||||
className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-gray-50/60 p-2`}
|
||||
style={treeNameCellStyle(depth)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-gray-400" />
|
||||
<span>Loading versions…</span>
|
||||
<>
|
||||
{Array.from({ length: skeletonCount }).map((_, index) => (
|
||||
<div
|
||||
key={`ver-skeleton-${docId}-${index}`}
|
||||
className="flex h-10 items-center pr-8 bg-gray-100"
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} bg-gray-100 py-2 pl-4 pr-2`}
|
||||
style={treeNameCellStyle(depth)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-200 animate-pulse" />
|
||||
<div className="h-4 w-4 shrink-0 rounded bg-gray-200 animate-pulse" />
|
||||
<div className="h-3 w-32 rounded bg-gray-200 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto w-20 shrink-0">
|
||||
<div className="h-3 w-8 rounded bg-gray-200 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-24 shrink-0">
|
||||
<div className="h-3 w-10 rounded bg-gray-200 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-20 shrink-0 pl-1">
|
||||
<div className="h-3 w-5 rounded bg-gray-200 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-32 shrink-0">
|
||||
<div className="h-3 w-16 rounded bg-gray-200 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-32 shrink-0">
|
||||
<div className="h-3 w-10 rounded bg-gray-200 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-8 shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (versions.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center h-9 border-b border-gray-50 text-xs text-gray-400 bg-gray-50/60">
|
||||
<div className="flex items-center h-9 border-b border-gray-50 text-xs text-gray-400 bg-gray-50/80">
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} bg-gray-50/60 self-stretch`}
|
||||
style={treeControlCellStyle(depth)}
|
||||
/>
|
||||
<div
|
||||
className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-gray-50/60 p-2`}
|
||||
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} bg-gray-50/80 py-2 pl-4 pr-2`}
|
||||
style={treeNameCellStyle(depth)}
|
||||
>
|
||||
<div>No version history.</div>
|
||||
|
|
@ -150,7 +175,10 @@ export function DocVersionHistory({
|
|||
);
|
||||
}
|
||||
|
||||
const ordered = [...versions].reverse();
|
||||
const olderVersions = versions.filter((v) => v.id !== currentVersionId);
|
||||
if (olderVersions.length === 0) return null;
|
||||
|
||||
const ordered = [...olderVersions].reverse();
|
||||
return (
|
||||
<>
|
||||
{ordered.map((v) => {
|
||||
|
|
@ -161,7 +189,7 @@ export function DocVersionHistory({
|
|||
: v.source === "upload"
|
||||
? "Original"
|
||||
: "—";
|
||||
const displayLabel = v.display_name?.trim() || numberLabel;
|
||||
const displayLabel = v.filename?.trim() || numberLabel;
|
||||
const dt = new Date(v.created_at);
|
||||
const dateLabel = Number.isNaN(dt.valueOf())
|
||||
? ""
|
||||
|
|
@ -173,7 +201,7 @@ export function DocVersionHistory({
|
|||
minute: "2-digit",
|
||||
});
|
||||
const isEditing = editingVersionId === v.id;
|
||||
|
||||
const rowBg = "bg-gray-100";
|
||||
return (
|
||||
<div
|
||||
key={`ver-${docId}-${v.id}`}
|
||||
|
|
@ -181,20 +209,20 @@ export function DocVersionHistory({
|
|||
if (isEditing) return;
|
||||
onOpenVersion?.(v.id, displayLabel);
|
||||
}}
|
||||
className="group flex items-center h-9 pr-3 md:pr-10 border-b border-gray-50 bg-gray-50/60 text-xs text-gray-600 cursor-pointer hover:bg-gray-100/80 transition-colors"
|
||||
className={`group flex h-10 cursor-pointer items-center pr-8 text-sm text-gray-500 transition-colors hover:bg-gray-200 ${rowBg}`}
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} bg-gray-50/60 group-hover:bg-gray-100/80 self-stretch`}
|
||||
style={treeControlCellStyle(depth)}
|
||||
/>
|
||||
<div
|
||||
className={`sticky left-8 z-[60] ${DOC_NAME_COL_W} bg-gray-50/60 group-hover:bg-gray-100/80 p-2`}
|
||||
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${rowBg} py-2 pl-4 pr-2 transition-colors group-hover:bg-gray-200`}
|
||||
style={treeNameCellStyle(depth)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="shrink-0 text-gray-400">
|
||||
↳
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex h-2.5 w-2.5 shrink-0 items-center justify-center">
|
||||
<CornerDownRight
|
||||
className="h-3.5 w-3.5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<DocIcon fileType={fileType} />
|
||||
{isEditing ? (
|
||||
<input
|
||||
autoFocus
|
||||
|
|
@ -212,53 +240,48 @@ export function DocVersionHistory({
|
|||
}
|
||||
}}
|
||||
onBlur={() => void commit(v.id)}
|
||||
className="min-w-0 flex-1 max-w-[240px] border-b border-gray-300 bg-transparent text-xs text-gray-800 outline-none focus:border-gray-500"
|
||||
className="min-w-0 flex-1 border-b border-gray-300 bg-transparent text-sm text-gray-800 outline-none focus:border-gray-500"
|
||||
/>
|
||||
) : (
|
||||
<span className="font-medium text-gray-700 truncate">
|
||||
<span className="truncate text-sm text-gray-700">
|
||||
{displayLabel}
|
||||
</span>
|
||||
)}
|
||||
{!isEditing && onRenameVersion && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingVersionId(v.id);
|
||||
setEditingValue(
|
||||
v.display_name ?? "",
|
||||
);
|
||||
}}
|
||||
title="Rename version"
|
||||
className="shrink-0 rounded p-0.5 text-gray-400 opacity-0 group-hover:opacity-100 hover:text-gray-700 hover:bg-gray-200 transition"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<span className="text-gray-400 truncate">
|
||||
{dateLabel}
|
||||
</span>
|
||||
<span className="text-gray-300 shrink-0">
|
||||
·
|
||||
</span>
|
||||
<span className="text-gray-400 truncate">
|
||||
{v.source}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto w-20 shrink-0" />
|
||||
<div className="w-24 shrink-0" />
|
||||
<div className="ml-auto w-20 shrink-0" />
|
||||
<div className="w-8 shrink-0 flex justify-end">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownloadVersion(docId, v.id, filename);
|
||||
}}
|
||||
title="Download this version"
|
||||
className="flex items-center justify-center w-6 h-6 rounded text-gray-500 hover:text-gray-800 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div className="ml-auto w-20 shrink-0 truncate text-xs uppercase text-gray-500">
|
||||
{fileType ?? <span className="text-gray-300">—</span>}
|
||||
</div>
|
||||
<div className="w-24 shrink-0 truncate text-sm text-gray-400">
|
||||
—
|
||||
</div>
|
||||
<div className="w-20 shrink-0 truncate pl-1 text-sm text-gray-500">
|
||||
{numberLabel}
|
||||
</div>
|
||||
<div className="w-32 shrink-0 truncate text-sm text-gray-500">
|
||||
{dateLabel ? formatDate(v.created_at) : <span className="text-gray-300">—</span>}
|
||||
</div>
|
||||
<div className="w-32 shrink-0 truncate text-sm text-gray-400">
|
||||
—
|
||||
</div>
|
||||
<div
|
||||
className="w-8 shrink-0 flex justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<RowActions
|
||||
onRename={
|
||||
onRenameVersion
|
||||
? () => {
|
||||
setEditingVersionId(v.id);
|
||||
setEditingValue(v.filename ?? "");
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
renameLabel="Rename version"
|
||||
onDownload={() =>
|
||||
onDownloadVersion(docId, v.id, filename)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -269,20 +292,43 @@ export function DocVersionHistory({
|
|||
|
||||
export function ProjectPageSkeleton() {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-white">
|
||||
<div className="mb-1 flex items-start justify-between px-4 py-3 md:px-10">
|
||||
<div className="flex items-center gap-1.5 text-2xl font-medium font-serif">
|
||||
<span className="text-gray-400">Projects</span>
|
||||
<span className="text-gray-300">›</span>
|
||||
<div className="h-6 w-40 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-8 w-8 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-8 w-8 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-8 w-11 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-8 w-28 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<PageHeader
|
||||
align="start"
|
||||
actionGap="lg"
|
||||
breadcrumbs={[
|
||||
{ label: "Projects" },
|
||||
{ loading: true, skeletonClassName: "w-40" },
|
||||
]}
|
||||
actionGroups={[
|
||||
[
|
||||
{
|
||||
disabled: true,
|
||||
iconOnly: true,
|
||||
title: "Search",
|
||||
icon: <Search className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
disabled: true,
|
||||
iconOnly: true,
|
||||
title: "People with access",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
disabled: true,
|
||||
icon: <MessageSquare className="h-4 w-4" />,
|
||||
label: <span className="hidden sm:inline">New Chat</span>,
|
||||
},
|
||||
{
|
||||
disabled: true,
|
||||
icon: <Table2 className="h-4 w-4" />,
|
||||
label: <span className="hidden sm:inline">New Review</span>,
|
||||
},
|
||||
],
|
||||
]}
|
||||
/>
|
||||
<div className="flex items-center h-10 px-4 md:px-10 border-b border-gray-200 gap-5">
|
||||
<div className="h-3 w-20 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-3 w-10 rounded bg-gray-100 animate-pulse" />
|
||||
|
|
@ -293,8 +339,8 @@ export function ProjectPageSkeleton() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200">
|
||||
<div className="w-8 shrink-0" />
|
||||
<div className="flex-1 min-w-0 pl-3 pr-4">
|
||||
<div className={`${DOC_NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}>
|
||||
<div className="h-2.5 w-2.5 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-2.5 w-8 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-20 shrink-0">
|
||||
|
|
@ -310,8 +356,8 @@ export function ProjectPageSkeleton() {
|
|||
key={i}
|
||||
className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
|
||||
>
|
||||
<div className="w-8 shrink-0" />
|
||||
<div className="flex-1 min-w-0 pl-3 pr-4">
|
||||
<div className={`${DOC_NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}>
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-3.5 w-56 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-20 shrink-0">
|
||||
|
|
@ -335,21 +381,19 @@ export function ProjectPageHeader({
|
|||
creatingReview,
|
||||
docsCount,
|
||||
onBackToProjects,
|
||||
onOpenDocuments,
|
||||
onTitleCommit,
|
||||
onSearchChange,
|
||||
onOpenPeople,
|
||||
onNewChat,
|
||||
onNewReview,
|
||||
}: {
|
||||
project: MikeProject;
|
||||
project: Project;
|
||||
tab: ProjectTab;
|
||||
search: string;
|
||||
creatingChat: boolean;
|
||||
creatingReview: boolean;
|
||||
docsCount: number;
|
||||
onBackToProjects: () => void;
|
||||
onOpenDocuments: () => void;
|
||||
onTitleCommit: (newName: string) => void | Promise<void>;
|
||||
onSearchChange: (search: string) => void;
|
||||
onOpenPeople: () => void;
|
||||
|
|
@ -357,109 +401,88 @@ export function ProjectPageHeader({
|
|||
onNewReview: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-1 flex items-start justify-between px-4 py-3 md:px-10">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-2xl font-medium font-serif">
|
||||
<button
|
||||
onClick={onBackToProjects}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
Projects
|
||||
</button>
|
||||
<span className="text-gray-300">›</span>
|
||||
{tab !== "documents" ? (
|
||||
<button
|
||||
onClick={onOpenDocuments}
|
||||
className="text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
{project.name}
|
||||
{project.cm_number ? (
|
||||
<span className="ml-1 text-gray-400">
|
||||
(#{project.cm_number})
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
) : (
|
||||
<PageHeader
|
||||
breadcrumbs={[
|
||||
{
|
||||
label: "Projects",
|
||||
onClick: onBackToProjects,
|
||||
title: "Back to Projects",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<RenameableTitle
|
||||
value={project.name}
|
||||
onCommit={onTitleCommit}
|
||||
suffix={
|
||||
project.cm_number ? (
|
||||
<span className="ml-1 text-gray-400">
|
||||
(#{project.cm_number})
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{tab !== "documents" && (
|
||||
<>
|
||||
<span className="text-gray-300">›</span>
|
||||
<span className="text-gray-900">
|
||||
{tab === "assistant"
|
||||
? "Assistant"
|
||||
: "Tabular Reviews"}
|
||||
),
|
||||
suffix: project.cm_number ? (
|
||||
<span className="ml-1 text-gray-400">
|
||||
(#{project.cm_number})
|
||||
</span>
|
||||
) : null,
|
||||
},
|
||||
]}
|
||||
align="start"
|
||||
actionGap="lg"
|
||||
actionGroups={[
|
||||
[
|
||||
{
|
||||
type: "search",
|
||||
value: search,
|
||||
onChange: onSearchChange,
|
||||
placeholder: "Search…",
|
||||
},
|
||||
{
|
||||
onClick: onOpenPeople,
|
||||
iconOnly: true,
|
||||
title: "People with access",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
onClick: onNewChat,
|
||||
disabled: creatingChat,
|
||||
icon: creatingChat ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
),
|
||||
label: <span className="hidden sm:inline">New Chat</span>,
|
||||
},
|
||||
{
|
||||
onClick: onNewReview,
|
||||
disabled: docsCount === 0 || creatingReview,
|
||||
icon: creatingReview ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Table2 className="h-4 w-4" />
|
||||
),
|
||||
label: (
|
||||
<span className="hidden sm:inline">
|
||||
New Review
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<HeaderSearchBtn
|
||||
value={search}
|
||||
onChange={onSearchChange}
|
||||
placeholder="Search…"
|
||||
/>
|
||||
<button
|
||||
onClick={onOpenPeople}
|
||||
className="flex h-8 w-8 items-center justify-center text-sm text-gray-500 transition-colors hover:text-gray-900 cursor-pointer"
|
||||
title="People with access"
|
||||
aria-label="People with access"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="relative group">
|
||||
<button
|
||||
onClick={() => !creatingChat && onNewChat()}
|
||||
className={`flex h-8 items-center justify-center gap-1.5 text-sm transition-colors ${
|
||||
!creatingChat
|
||||
? "text-gray-500 hover:text-gray-900 cursor-pointer"
|
||||
: "text-gray-300 cursor-default"
|
||||
}`}
|
||||
>
|
||||
{creatingChat ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
Chat
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative group">
|
||||
<button
|
||||
onClick={() =>
|
||||
docsCount > 0 && !creatingReview && onNewReview()
|
||||
}
|
||||
className={`flex h-8 items-center justify-center gap-1.5 text-sm transition-colors ${
|
||||
docsCount > 0
|
||||
? "text-gray-500 hover:text-gray-900 cursor-pointer"
|
||||
: "text-gray-300 cursor-default"
|
||||
}`}
|
||||
>
|
||||
{creatingReview ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
Tabular Review
|
||||
</button>
|
||||
{docsCount === 0 && (
|
||||
<div className="pointer-events-none absolute right-0 top-full mt-1.5 z-10 hidden group-hover:flex items-center whitespace-nowrap rounded-lg bg-gray-900 px-2.5 py-1.5 text-xs text-white shadow-lg">
|
||||
Upload a document first
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
tooltip: docsCount === 0 ? "Upload a document first" : null,
|
||||
},
|
||||
],
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function filenameExtension(filename: string) {
|
||||
const trimmed = filename.trim();
|
||||
const dotIndex = trimmed.lastIndexOf(".");
|
||||
if (dotIndex <= 0 || dotIndex === trimmed.length - 1) return null;
|
||||
return trimmed.slice(dotIndex);
|
||||
}
|
||||
|
||||
function hasFilenameExtensionChange(previous: string, next: string) {
|
||||
const previousExtension = filenameExtension(previous);
|
||||
if (previousExtension == null) return false;
|
||||
return (
|
||||
filenameExtension(next)?.toLowerCase() !==
|
||||
previousExtension.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
import { type Dispatch, type SetStateAction } from "react";
|
||||
import { Table2 } from "lucide-react";
|
||||
import { RowActions } from "@/app/components/shared/RowActions";
|
||||
import type { MikeDocument, TabularReview } from "@/app/components/shared/types";
|
||||
import { CHECK_W, formatDate, NAME_COL_W } from "./ProjectPageParts";
|
||||
import type { Document, TabularReview } from "@/app/components/shared/types";
|
||||
import { formatDate, NAME_COL_W } from "./ProjectPageParts";
|
||||
|
||||
export function ProjectReviewsTab({
|
||||
docs,
|
||||
|
|
@ -26,7 +26,7 @@ export function ProjectReviewsTab({
|
|||
setRenamingReviewId,
|
||||
setRenameReviewValue,
|
||||
}: {
|
||||
docs: MikeDocument[];
|
||||
docs: Document[];
|
||||
reviews: TabularReview[];
|
||||
filteredReviews: TabularReview[];
|
||||
selectedReviewIds: string[];
|
||||
|
|
@ -45,12 +45,12 @@ export function ProjectReviewsTab({
|
|||
setRenamingReviewId: Dispatch<SetStateAction<string | null>>;
|
||||
setRenameReviewValue: Dispatch<SetStateAction<string>>;
|
||||
}) {
|
||||
const stickyCellBg = "bg-[#fcfcfd]";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center h-8 pr-8 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}
|
||||
>
|
||||
<div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allReviewsSelected}
|
||||
|
|
@ -66,11 +66,7 @@ export function ProjectReviewsTab({
|
|||
}}
|
||||
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white pl-2 text-left`}
|
||||
>
|
||||
Name
|
||||
<span>Name</span>
|
||||
</div>
|
||||
<div className="ml-auto w-24 shrink-0 text-left">Columns</div>
|
||||
<div className="w-24 shrink-0 text-left">Documents</div>
|
||||
|
|
@ -103,58 +99,52 @@ export function ProjectReviewsTab({
|
|||
if (renamingReviewId === review.id) return;
|
||||
onOpenReview(review.id);
|
||||
}}
|
||||
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-100 cursor-pointer transition-colors"
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${
|
||||
selectedReviewIds.includes(review.id)
|
||||
? "bg-gray-50"
|
||||
: "bg-white"
|
||||
} group-hover:bg-gray-50`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`sticky left-0 z-[60] ${NAME_COL_W} ${selectedReviewIds.includes(review.id) ? "bg-gray-50" : stickyCellBg} py-2 pl-4 pr-2 transition-colors group-hover:bg-gray-100`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedReviewIds.includes(review.id)}
|
||||
onChange={() =>
|
||||
setSelectedReviewIds((prev) =>
|
||||
prev.includes(review.id)
|
||||
? prev.filter(
|
||||
(x) => x !== review.id,
|
||||
)
|
||||
: [...prev, review.id],
|
||||
)
|
||||
}
|
||||
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`}
|
||||
>
|
||||
{renamingReviewId === review.id ? (
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<input
|
||||
autoFocus
|
||||
value={renameReviewValue}
|
||||
onChange={(e) =>
|
||||
setRenameReviewValue(e.target.value)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter")
|
||||
void submitReviewRename(review.id);
|
||||
if (e.key === "Escape")
|
||||
setRenamingReviewId(null);
|
||||
}}
|
||||
onBlur={() =>
|
||||
void submitReviewRename(review.id)
|
||||
type="checkbox"
|
||||
checked={selectedReviewIds.includes(review.id)}
|
||||
onChange={() =>
|
||||
setSelectedReviewIds((prev) =>
|
||||
prev.includes(review.id)
|
||||
? prev.filter(
|
||||
(x) => x !== review.id,
|
||||
)
|
||||
: [...prev, review.id],
|
||||
)
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full text-sm text-gray-800 bg-transparent outline-none"
|
||||
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-gray-800 truncate block">
|
||||
{review.title ?? "Untitled Review"}
|
||||
</span>
|
||||
)}
|
||||
{renamingReviewId === review.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameReviewValue}
|
||||
onChange={(e) =>
|
||||
setRenameReviewValue(e.target.value)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter")
|
||||
void submitReviewRename(review.id);
|
||||
if (e.key === "Escape")
|
||||
setRenamingReviewId(null);
|
||||
}}
|
||||
onBlur={() =>
|
||||
void submitReviewRename(review.id)
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="min-w-0 flex-1 text-sm text-gray-800 bg-transparent outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-gray-800">
|
||||
{review.title ?? "Untitled Review"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto w-24 shrink-0 text-sm text-gray-500 truncate">
|
||||
{review.columns_config?.length ?? 0}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@
|
|||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus, FolderOpen, ChevronDown } from "lucide-react";
|
||||
import { HeaderSearchBtn } from "@/app/components/shared/HeaderSearchBtn";
|
||||
import { FolderOpen, ChevronDown } from "lucide-react";
|
||||
import { listProjects, updateProject, deleteProject } from "@/app/lib/mikeApi";
|
||||
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import type { MikeProject } from "@/app/components/shared/types";
|
||||
import type { Project } from "@/app/components/shared/types";
|
||||
import { NewProjectModal } from "./NewProjectModal";
|
||||
import { ToolbarTabs } from "@/app/components/shared/ToolbarTabs";
|
||||
import { RowActions } from "@/app/components/shared/RowActions";
|
||||
import { PageHeader } from "@/app/components/shared/PageHeader";
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
|
|
@ -22,11 +22,10 @@ function formatDate(iso: string) {
|
|||
|
||||
type Tab = "all" | "mine" | "shared-with-me";
|
||||
|
||||
const CHECK_W = "w-8 shrink-0";
|
||||
const NAME_COL_W = "w-[300px] shrink-0";
|
||||
const NAME_COL_W = "w-[332px] shrink-0";
|
||||
|
||||
export function ProjectsOverview() {
|
||||
const [projects, setProjects] = useState<MikeProject[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
|
@ -42,6 +41,7 @@ export function ProjectsOverview() {
|
|||
const actionsRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
const { user, isAuthenticated, authLoading } = useAuth();
|
||||
const stickyCellBg = "bg-[#fcfcfd]";
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) {
|
||||
|
|
@ -203,26 +203,27 @@ export function ProjectsOverview() {
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-white">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Page header */}
|
||||
<div className="mb-1 flex items-center justify-between px-4 py-3 md:px-10">
|
||||
<PageHeader
|
||||
actions={[
|
||||
{
|
||||
type: "search",
|
||||
value: search,
|
||||
onChange: setSearch,
|
||||
placeholder: "Search projects…",
|
||||
},
|
||||
{
|
||||
type: "new",
|
||||
onClick: () => setModalOpen(true),
|
||||
title: "New project",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<h1 className="text-2xl font-medium font-serif text-gray-900">
|
||||
Projects
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<HeaderSearchBtn
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search projects…"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="flex items-center justify-center p-1.5 text-gray-500 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<ToolbarTabs
|
||||
tabs={tabs}
|
||||
|
|
@ -236,8 +237,10 @@ export function ProjectsOverview() {
|
|||
<div className="min-w-max">
|
||||
{/* Column headers */}
|
||||
<div className="flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
|
||||
<div className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}>
|
||||
{!loading && (
|
||||
<div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}>
|
||||
{loading ? (
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
|
|
@ -248,9 +251,7 @@ export function ProjectsOverview() {
|
|||
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white pl-2 text-left`}>
|
||||
Name
|
||||
<span>Name</span>
|
||||
</div>
|
||||
<div className="ml-auto w-32 shrink-0 text-left">CM</div>
|
||||
<div className="w-24 shrink-0 text-left">Files</div>
|
||||
|
|
@ -269,8 +270,8 @@ export function ProjectsOverview() {
|
|||
key={i}
|
||||
className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
|
||||
>
|
||||
<div className="w-8 shrink-0" />
|
||||
<div className="flex-1 min-w-0 pl-3 pr-4">
|
||||
<div className={`${NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}>
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-3.5 w-48 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-32 shrink-0">
|
||||
|
|
@ -333,7 +334,7 @@ export function ProjectsOverview() {
|
|||
{filtered.map((project) => {
|
||||
const rowBg = selectedIds.includes(project.id)
|
||||
? "bg-gray-50"
|
||||
: "bg-white";
|
||||
: stickyCellBg;
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
|
|
@ -341,50 +342,47 @@ export function ProjectsOverview() {
|
|||
if (renamingId === project.id) return;
|
||||
router.push(`/projects/${project.id}`);
|
||||
}}
|
||||
className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-50 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 cursor-pointer transition-colors"
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${rowBg} group-hover:bg-gray-50`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(
|
||||
project.id,
|
||||
)}
|
||||
onChange={() => toggleOne(project.id)}
|
||||
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project Name */}
|
||||
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2 group-hover:bg-gray-50`}>
|
||||
{renamingId === project.id ? (
|
||||
<div className={`sticky left-0 z-[60] ${NAME_COL_W} ${rowBg} py-2 pl-4 pr-2 transition-colors group-hover:bg-gray-100`}>
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) =>
|
||||
setRenameValue(e.target.value)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter")
|
||||
handleRenameSubmit(
|
||||
project.id,
|
||||
);
|
||||
if (e.key === "Escape")
|
||||
setRenamingId(null);
|
||||
}}
|
||||
onBlur={() =>
|
||||
handleRenameSubmit(project.id)
|
||||
}
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(
|
||||
project.id,
|
||||
)}
|
||||
onChange={() => toggleOne(project.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full text-sm text-gray-800 bg-transparent outline-none"
|
||||
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-gray-800 truncate block">
|
||||
{project.name}
|
||||
</span>
|
||||
)}
|
||||
{renamingId === project.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) =>
|
||||
setRenameValue(e.target.value)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter")
|
||||
handleRenameSubmit(
|
||||
project.id,
|
||||
);
|
||||
if (e.key === "Escape")
|
||||
setRenamingId(null);
|
||||
}}
|
||||
onBlur={() =>
|
||||
handleRenameSubmit(project.id)
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="min-w-0 flex-1 text-sm text-gray-800 bg-transparent outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-gray-800">
|
||||
{project.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,26 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, Upload, Search, Loader2 } from "lucide-react";
|
||||
import { AlertCircle, Upload, Search, Loader2, X } from "lucide-react";
|
||||
import {
|
||||
uploadStandaloneDocument,
|
||||
uploadProjectDocument,
|
||||
addDocumentToProject,
|
||||
deleteDocument,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import type { MikeDocument } from "./types";
|
||||
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: MikeDocument[], projectId?: string) => void;
|
||||
onSelect: (documents: Document[], projectId?: string) => void;
|
||||
breadcrumb: string[];
|
||||
allowMultiple?: boolean;
|
||||
projectId?: string;
|
||||
|
|
@ -39,8 +44,9 @@ export function AddDocumentsModal({
|
|||
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<MikeDocument[]>([]);
|
||||
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());
|
||||
|
|
@ -54,6 +60,7 @@ export function AddDocumentsModal({
|
|||
setExtraUploadedDocs([]);
|
||||
setDeletedIds(new Set());
|
||||
setUploadingFilenames([]);
|
||||
setUploadWarning(null);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
|
@ -68,7 +75,9 @@ export function AddDocumentsModal({
|
|||
].filter((d) => !deletedIds.has(d.id));
|
||||
|
||||
const filteredStandalone = q
|
||||
? allStandalone.filter((d) => d.filename.toLowerCase().includes(q))
|
||||
? allStandalone.filter((d) =>
|
||||
d.filename.toLowerCase().includes(q),
|
||||
)
|
||||
: allStandalone;
|
||||
|
||||
const filteredProjects = projects
|
||||
|
|
@ -78,7 +87,8 @@ export function AddDocumentsModal({
|
|||
documents: (p.documents || []).filter(
|
||||
(d) =>
|
||||
!deletedIds.has(d.id) &&
|
||||
(!q || d.filename.toLowerCase().includes(q)),
|
||||
(!q ||
|
||||
d.filename.toLowerCase().includes(q)),
|
||||
),
|
||||
}))
|
||||
.filter(
|
||||
|
|
@ -134,7 +144,7 @@ export function AddDocumentsModal({
|
|||
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, MikeDocument>();
|
||||
const docsById = new Map<string, Document>();
|
||||
for (const d of [
|
||||
...standaloneDocuments,
|
||||
...extraUploadedDocs,
|
||||
|
|
@ -177,11 +187,17 @@ export function AddDocumentsModal({
|
|||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
setUploadingFilenames(files.map((file) => file.name));
|
||||
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(
|
||||
files.map((f) =>
|
||||
supported.map((f) =>
|
||||
projectId
|
||||
? uploadProjectDocument(projectId, f)
|
||||
: uploadStandaloneDocument(f),
|
||||
|
|
@ -201,29 +217,45 @@ export function AddDocumentsModal({
|
|||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs">
|
||||
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
{breadcrumb.map((segment, i) => (
|
||||
<span key={i} className="flex items-center gap-1.5">
|
||||
{i > 0 && <span>›</span>}
|
||||
{segment}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
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="px-4 pt-1 pb-2">
|
||||
<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
|
||||
|
|
@ -245,76 +277,40 @@ export function AddDocumentsModal({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* File browser */}
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-2">
|
||||
<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}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-gray-100 px-4 py-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.docx,.doc"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{uploading ? "Uploading…" : "Upload"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedIds.size > 0 && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedIds.size === 0 || uploading}
|
||||
className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40"
|
||||
>
|
||||
{uploading ? "Saving…" : "Confirm"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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)}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check, Loader2, Search, Upload, X } from "lucide-react";
|
||||
import { getProject, uploadProjectDocument } from "@/app/lib/mikeApi";
|
||||
import type { MikeDocument } from "./types";
|
||||
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: MikeDocument[]) => void;
|
||||
onSelect: (documents: Document[]) => void;
|
||||
breadcrumb: string[];
|
||||
projectId: string;
|
||||
/** Docs already in the target list — rendered checked + disabled. */
|
||||
|
|
@ -37,7 +37,7 @@ export function AddProjectDocsModal({
|
|||
excludeDocIds,
|
||||
allowMultiple = true,
|
||||
}: Props) {
|
||||
const [docs, setDocs] = useState<MikeDocument[]>([]);
|
||||
const [docs, setDocs] = useState<Document[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
|
@ -115,185 +115,147 @@ export function AddProjectDocsModal({
|
|||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs">
|
||||
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
{breadcrumb.map((segment, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{i > 0 && <span>›</span>}
|
||||
{segment}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-4 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 */}
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-2">
|
||||
{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.latest_version_number}
|
||||
/>
|
||||
{doc.created_at && (
|
||||
<span className="shrink-0 text-gray-300">
|
||||
{formatDate(doc.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
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>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-gray-100 px-4 py-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.docx,.doc"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{uploading ? "Uploading…" : "Upload"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedIds.size > 0 && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedIds.size === 0 || uploading}
|
||||
className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
{/* 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,9 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { providerLabel, type ModelProvider } from "@/app/lib/modelAvailability";
|
||||
import { WarningPopup } from "./WarningPopup";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
|
@ -27,52 +27,19 @@ export function ApiKeyMissingModal({ open, onClose, provider, message }: Props)
|
|||
router.push("/account/models");
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-2xl bg-white shadow-2xl flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 px-5 pt-5 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
||||
<h2 className="text-base font-medium text-gray-900">
|
||||
API key required
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pb-2 pt-1">
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
{body}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 px-5 pb-5 pt-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-4 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGoToAccount}
|
||||
className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700"
|
||||
>
|
||||
Go to account settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
PanelLeft,
|
||||
MessageSquare,
|
||||
|
|
@ -19,7 +19,8 @@ import Link from "next/link";
|
|||
import { MikeIcon } from "@/components/chat/mike-icon";
|
||||
import { SidebarChatItem } from "@/app/components/shared/SidebarChatItem";
|
||||
import { listProjects } from "@/app/lib/mikeApi";
|
||||
import type { MikeProject } from "@/app/components/shared/types";
|
||||
import type { Project } from "@/app/components/shared/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/assistant", label: "Assistant", icon: MessageSquare },
|
||||
|
|
@ -36,15 +37,20 @@ interface AppSidebarProps {
|
|||
export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
||||
const { user } = useAuth();
|
||||
const { profile } = useUserProfile();
|
||||
const {
|
||||
chats,
|
||||
currentChatId,
|
||||
hasMoreChats,
|
||||
loadMoreChats,
|
||||
setCurrentChatId,
|
||||
} = useChatHistoryContext();
|
||||
const { chats, hasMoreChats, loadMoreChats, setCurrentChatId } =
|
||||
useChatHistoryContext();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const routeChatId = useMemo(() => {
|
||||
if (pathname.startsWith("/assistant/chat/")) {
|
||||
return pathname.split("/").pop() ?? null;
|
||||
}
|
||||
|
||||
const projectChatMatch = pathname.match(
|
||||
/^\/projects\/[^/]+\/assistant\/chat\/([^/]+)/,
|
||||
);
|
||||
return projectChatMatch?.[1] ?? null;
|
||||
}, [pathname]);
|
||||
const [shouldAnimate, setShouldAnimate] = useState(false);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [projectsCollapsed, setProjectsCollapsed] = useState(false);
|
||||
|
|
@ -52,7 +58,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
const [projectNames, setProjectNames] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
const [recentProjects, setRecentProjects] = useState<MikeProject[] | null>(
|
||||
const [recentProjects, setRecentProjects] = useState<Project[] | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
|
|
@ -93,24 +99,8 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
}, [isDropdownOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith("/assistant/chat/")) {
|
||||
const chatId = pathname.split("/").pop() ?? null;
|
||||
setCurrentChatId(chatId);
|
||||
return;
|
||||
}
|
||||
|
||||
const projectChatMatch = pathname.match(
|
||||
/^\/projects\/[^/]+\/assistant\/chat\/([^/]+)/,
|
||||
);
|
||||
if (projectChatMatch) {
|
||||
setCurrentChatId(projectChatMatch[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/assistant") {
|
||||
setCurrentChatId(null);
|
||||
}
|
||||
}, [pathname, setCurrentChatId]);
|
||||
setCurrentChatId(routeChatId);
|
||||
}, [routeChatId, setCurrentChatId]);
|
||||
|
||||
const getUserInitials = (email: string) => {
|
||||
if (profile?.displayName)
|
||||
|
|
@ -132,11 +122,13 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
className={cn(
|
||||
isOpen
|
||||
? "w-64 h-dvh bg-gray-50 border-r"
|
||||
: "w-14 md:h-dvh md:bg-gray-50 md:border-r h-auto bg-transparent pointer-events-none md:pointer-events-auto"
|
||||
} border-gray-200 flex flex-col transition-all duration-300 absolute md:relative z-[99] overflow-visible`}
|
||||
? "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",
|
||||
"flex flex-col transition-all duration-300 absolute md:relative z-[99]",
|
||||
)}
|
||||
>
|
||||
{/* Toggle + Logo */}
|
||||
<div
|
||||
|
|
@ -145,7 +137,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
}`}
|
||||
>
|
||||
{isOpen && (
|
||||
<div className="px-2.5">
|
||||
<div className="px-2">
|
||||
<Link
|
||||
href="/assistant"
|
||||
className="flex items-center gap-1.5 hover:opacity-80 transition-opacity"
|
||||
|
|
@ -163,7 +155,10 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
)}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="h-9 w-9 p-2.5 items-center flex hover:bg-gray-100 rounded-md transition-colors"
|
||||
className={cn(
|
||||
"h-9 w-9 p-2.5 items-center flex transition-colors",
|
||||
"rounded-xl hover:bg-gray-100",
|
||||
)}
|
||||
title={isOpen ? "Close sidebar" : "Open sidebar"}
|
||||
>
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
|
|
@ -173,17 +168,24 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
{/* Nav items */}
|
||||
{NAV_ITEMS.map(({ href, label, icon: Icon }) => {
|
||||
const isActive =
|
||||
pathname === href || pathname.startsWith(href + "/");
|
||||
href === "/assistant"
|
||||
? pathname === href
|
||||
: href === "/projects"
|
||||
? pathname === href
|
||||
: pathname === href ||
|
||||
pathname.startsWith(href + "/");
|
||||
return (
|
||||
<div key={href} className="py-0.5 px-2.5">
|
||||
<button
|
||||
onClick={() => router.push(href)}
|
||||
title={!isOpen ? label : ""}
|
||||
className={`w-full h-9 flex items-center gap-3 px-2.5 py-2 rounded-md transition-colors text-left ${
|
||||
className={cn(
|
||||
"w-full h-9 flex items-center gap-3 px-2.5 py-2 rounded-md transition-colors text-left",
|
||||
isActive
|
||||
? "bg-gray-100 text-gray-900"
|
||||
: "hover:bg-gray-100 text-gray-700"
|
||||
} ${!isOpen ? "hidden md:flex" : "flex"}`}
|
||||
? "bg-gray-200/60 text-gray-900"
|
||||
: "text-gray-700 hover:bg-gray-100",
|
||||
!isOpen ? "hidden md:flex" : "flex",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={`h-4 w-4 flex-shrink-0 ${
|
||||
|
|
@ -271,11 +273,12 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
)
|
||||
}
|
||||
title={project.name}
|
||||
className={`flex h-9 w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs transition-colors ${
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs transition-colors",
|
||||
isActive
|
||||
? "bg-gray-100 text-gray-900"
|
||||
: "text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
? "bg-gray-200/60 text-gray-900"
|
||||
: "text-gray-700 hover:bg-gray-100",
|
||||
)}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-gray-500" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
|
|
@ -346,7 +349,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
key={chat.id}
|
||||
chat={chat}
|
||||
isActive={
|
||||
currentChatId === chat.id
|
||||
routeChatId === chat.id
|
||||
}
|
||||
projectName={
|
||||
chat.project_id
|
||||
|
|
@ -370,7 +373,10 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
<div className="px-2.5 pt-1">
|
||||
<button
|
||||
onClick={loadMoreChats}
|
||||
className="flex h-8 w-full items-center justify-start rounded-md px-3 text-left text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700"
|
||||
className={cn(
|
||||
"flex h-8 w-full items-center justify-start rounded-md px-3 text-left text-xs font-medium text-gray-500 transition-colors hover:text-gray-700",
|
||||
"hover:bg-gray-100",
|
||||
)}
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
|
|
@ -384,21 +390,22 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
)}
|
||||
|
||||
{/* User Profile */}
|
||||
<div className="mt-auto">
|
||||
<div className="mt-auto p-1">
|
||||
{user && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
className={`flex items-center transition-colors w-full px-3.5 py-4 border-t border-gray-200 ${
|
||||
!isOpen ? "hidden md:flex" : ""
|
||||
} ${
|
||||
className={cn(
|
||||
"flex items-center transition-colors w-full px-2.5 py-3 border-t",
|
||||
"rounded-xl border-white/60",
|
||||
!isOpen ? "hidden md:flex" : "",
|
||||
pathname === "/account" || isDropdownOpen
|
||||
? "bg-gray-100"
|
||||
: "hover:bg-gray-100"
|
||||
}`}
|
||||
? "bg-gray-200/60"
|
||||
: "hover:bg-gray-100",
|
||||
)}
|
||||
title={!isOpen ? user.email : undefined}
|
||||
>
|
||||
<div className="h-7 w-7 flex-shrink-0 rounded-full bg-gray-700 flex items-center justify-center text-white text-sm font-medium font-serif">
|
||||
<div className="h-6.5 w-6.5 flex-shrink-0 rounded-full bg-gray-700 flex items-center justify-center text-white text-sm font-medium font-serif">
|
||||
{getUserInitials(user.email)}
|
||||
</div>
|
||||
{isOpen && (
|
||||
|
|
@ -421,13 +428,21 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
|
|||
</button>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute bottom-full left-0 m-1 bg-white rounded-lg shadow-lg border border-gray-200 p-1 z-50 w-62 whitespace-nowrap">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-full left-0 right-0 z-50 mb-1 p-1 whitespace-nowrap",
|
||||
"bg-white/80 rounded-xl shadow-[0_6px_17px_rgba(15,23,42,0.1)] border border-white/70 backdrop-blur-xl",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
router.push("/account");
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2 rounded-md"
|
||||
className={cn(
|
||||
"w-full px-4 py-2 text-left text-sm text-gray-700 flex items-center gap-2 rounded-md",
|
||||
"hover:bg-white/70",
|
||||
)}
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
Account Settings
|
||||
|
|
|
|||
104
frontend/src/app/components/shared/ConfirmPopup.tsx
Normal file
104
frontend/src/app/components/shared/ConfirmPopup.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"use client";
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { Loader2 } 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 resolvedConfirmLabel =
|
||||
confirmStatus === "loading" ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{progressiveLabel(normalizedConfirmLabel)}
|
||||
</span>
|
||||
) : confirmStatus === "complete" ? (
|
||||
completedLabel(normalizedConfirmLabel)
|
||||
) : (
|
||||
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/58 px-4 py-3 text-sm shadow-[0_8px_24px_rgba(15,23,42,0.13),inset_0_1px_0_rgba(255,255,255,0.92),inset_0_-10px_24px_rgba(255,255,255,0.2)] backdrop-blur-2xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{title && (
|
||||
<div className="text-sm font-medium text-gray-950">
|
||||
{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="rounded-full px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-100"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={resolvedConfirmDisabled}
|
||||
className="rounded-full bg-gray-950 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
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`;
|
||||
}
|
||||
|
|
@ -7,14 +7,19 @@ import { applyOptimisticResolution } from "../assistant/EditCard";
|
|||
import { DocView } from "./DocView";
|
||||
import { DocxView } from "./DocxView";
|
||||
import {
|
||||
displayCitationQuote,
|
||||
RelevantQuotes,
|
||||
type RelevantQuoteItem,
|
||||
} from "./RelevantQuotes";
|
||||
import {
|
||||
expandCitationToEntries,
|
||||
formatCitationPage,
|
||||
getDocumentCitationQuotes,
|
||||
} from "./types";
|
||||
import type {
|
||||
CitationQuote,
|
||||
MikeCitationAnnotation,
|
||||
MikeEditAnnotation,
|
||||
CitationAnnotation,
|
||||
DocumentCitationAnnotation,
|
||||
EditAnnotation,
|
||||
} from "./types";
|
||||
|
||||
function isDocxFilename(name: string): boolean {
|
||||
|
|
@ -24,16 +29,16 @@ function isDocxFilename(name: string): boolean {
|
|||
|
||||
/**
|
||||
* Discriminated-union describing what the panel is showing above the viewer.
|
||||
* - "document": no header card, no label — just the viewer.
|
||||
* - "citation": "Citation Quote" card with the quoted text and page ref.
|
||||
* - "edit": "Tracked Change" card with the diff + Accept/Reject.
|
||||
* - "document": title row + viewer.
|
||||
* - "citation": title row + relevant quote + viewer.
|
||||
* - "edit": title row + tracked change + viewer.
|
||||
*/
|
||||
export type DocPanelMode =
|
||||
| { kind: "document" }
|
||||
| { kind: "citation"; citation: MikeCitationAnnotation }
|
||||
| { kind: "citation"; citation: CitationAnnotation }
|
||||
| {
|
||||
kind: "edit";
|
||||
edit: MikeEditAnnotation;
|
||||
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
|
||||
|
|
@ -98,11 +103,42 @@ export function DocPanel({
|
|||
// 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;
|
||||
return expandCitationToEntries(mode.citation);
|
||||
}, [mode]);
|
||||
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;
|
||||
|
|
@ -116,64 +152,50 @@ export function DocPanel({
|
|||
}, [mode]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col px-3 pb-3">
|
||||
{mode.kind === "citation" ? (
|
||||
<CitationHeader
|
||||
<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}
|
||||
documentId={documentId}
|
||||
versionId={versionId}
|
||||
filename={filename}
|
||||
isReloading={isReloading}
|
||||
activeQuoteId={activeCitationQuoteId}
|
||||
onQuoteSelect={handleCitationQuoteSelect}
|
||||
/>
|
||||
) : mode.kind === "edit" ? (
|
||||
<TrackedChangeHeader
|
||||
mode={mode}
|
||||
documentId={documentId}
|
||||
versionId={versionId}
|
||||
filename={filename}
|
||||
isReloading={isReloading}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-2 py-2">
|
||||
<div className="mr-auto flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm text-gray-700">
|
||||
{filename}
|
||||
</span>
|
||||
{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>
|
||||
<DownloadButton
|
||||
documentId={documentId}
|
||||
versionId={versionId}
|
||||
filename={filename}
|
||||
isReloading={isReloading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{useDocxView ? (
|
||||
<DocxView
|
||||
documentId={documentId}
|
||||
versionId={versionId ?? undefined}
|
||||
quotes={quotes}
|
||||
highlightEdit={highlightEdit}
|
||||
warning={warning ?? null}
|
||||
onWarningDismiss={onWarningDismiss}
|
||||
initialScrollTop={initialScrollTop ?? null}
|
||||
onScrollChange={onScrollChange}
|
||||
/>
|
||||
) : (
|
||||
<DocView
|
||||
doc={{
|
||||
document_id: documentId,
|
||||
version_id: versionId,
|
||||
}}
|
||||
quotes={quotes}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
@ -182,68 +204,106 @@ export function DocPanel({
|
|||
// Header variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-xs font-medium text-gray-700">{children}</p>;
|
||||
}
|
||||
|
||||
function CitationHeader({
|
||||
citation,
|
||||
function DocumentTitleRow({
|
||||
documentId,
|
||||
versionId,
|
||||
filename,
|
||||
versionId,
|
||||
versionNumber,
|
||||
isReloading,
|
||||
}: {
|
||||
citation: MikeCitationAnnotation;
|
||||
documentId: string;
|
||||
versionId: string | null;
|
||||
filename: string;
|
||||
versionId: string | null;
|
||||
versionNumber: number | null;
|
||||
isReloading: boolean;
|
||||
}) {
|
||||
const displayQuote = displayCitationQuote(citation);
|
||||
const pagesLabel = formatCitationPage(citation);
|
||||
return (
|
||||
<div className="pt-2 pb-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<SectionLabel>Citation</SectionLabel>
|
||||
<div className="ml-auto shrink-0">
|
||||
<DownloadButton
|
||||
documentId={documentId}
|
||||
versionId={versionId}
|
||||
filename={filename}
|
||||
isReloading={isReloading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full rounded-md bg-gray-50 border border-gray-200 px-2 py-2">
|
||||
<p className="text-sm font-serif text-gray-600">
|
||||
“{displayQuote}”
|
||||
{pagesLabel && (
|
||||
<span className="ml-1 text-gray-400">
|
||||
({pagesLabel})
|
||||
<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>
|
||||
)}
|
||||
</p>
|
||||
</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,
|
||||
documentId,
|
||||
versionId,
|
||||
filename,
|
||||
isReloading,
|
||||
}: {
|
||||
mode: Extract<DocPanelMode, { kind: "edit" }>;
|
||||
documentId: string;
|
||||
versionId: string | null;
|
||||
filename: string;
|
||||
isReloading: boolean;
|
||||
}) {
|
||||
const { edit, isEditReloading, onResolveStart, onResolved, onError } = mode;
|
||||
return (
|
||||
<div className="pt-2 pb-3">
|
||||
<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">
|
||||
|
|
@ -254,12 +314,6 @@ function TrackedChangeHeader({
|
|||
onResolved={onResolved}
|
||||
onError={onError}
|
||||
/>
|
||||
<DownloadButton
|
||||
documentId={documentId}
|
||||
versionId={versionId}
|
||||
filename={filename}
|
||||
isReloading={isReloading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{edit.reason && (
|
||||
|
|
@ -294,7 +348,7 @@ function EditResolveButtons({
|
|||
onResolved,
|
||||
onError,
|
||||
}: {
|
||||
edit: MikeEditAnnotation;
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { MikeIcon } from "@/components/chat/mike-icon";
|
||||
import { Loader2, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { useFetchSingleDoc } from "@/app/hooks/useFetchSingleDoc";
|
||||
import { DocxView } from "./DocxView";
|
||||
import type { CitationQuote } from "./types";
|
||||
|
|
@ -17,6 +16,8 @@ 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;
|
||||
|
|
@ -42,6 +43,7 @@ type RenderedPage = {
|
|||
export function DocView({
|
||||
doc,
|
||||
quotes,
|
||||
quoteFocusKey,
|
||||
quote,
|
||||
fallbackPage,
|
||||
rounded = true,
|
||||
|
|
@ -495,9 +497,8 @@ export function DocView({
|
|||
useEffect(() => {
|
||||
if (!pdfDocRef.current) return;
|
||||
quoteListRef.current = quoteList;
|
||||
if (quoteList.length === 0) return;
|
||||
rehighlightQuotes(quoteList);
|
||||
}, [quoteKey, rehighlightQuotes]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [quoteKey, quoteFocusKey, rehighlightQuotes]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function handleZoomIn() {
|
||||
const next = Math.min(
|
||||
|
|
@ -536,13 +537,14 @@ export function DocView({
|
|||
<DocxView
|
||||
documentId={doc.document_id}
|
||||
quotes={quotes}
|
||||
quoteFocusKey={quoteFocusKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-col flex-1 overflow-hidden ${bordered ? "border border-gray-200" : ""} ${rounded ? "rounded-xl" : ""}`}
|
||||
className={`relative flex flex-col flex-1 overflow-hidden ${bordered ? "border border-gray-200" : ""} ${rounded ? "rounded-lg" : ""}`}
|
||||
>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
|
|
@ -550,7 +552,7 @@ export function DocView({
|
|||
>
|
||||
{loading && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<MikeIcon spin mike size={28} />
|
||||
<Loader2 className="h-7 w-7 animate-spin text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
|
|
|
|||
|
|
@ -5,16 +5,16 @@ import { createPortal } from "react-dom";
|
|||
import { Download, Trash2, X } from "lucide-react";
|
||||
import { DocView } from "./DocView";
|
||||
import { getDocumentUrl } from "@/app/lib/mikeApi";
|
||||
import type { MikeDocument } from "./types";
|
||||
import type { Document } from "./types";
|
||||
|
||||
interface Props {
|
||||
doc: MikeDocument | null;
|
||||
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: MikeDocument) => void;
|
||||
onDelete?: (doc: Document) => void;
|
||||
}
|
||||
|
||||
export function DocViewModal({
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { FileText, File, X, AlertCircle, Loader2 } from "lucide-react";
|
||||
import type { MikeDocument } from "./types";
|
||||
import type { Document } from "./types";
|
||||
|
||||
interface Props {
|
||||
document: MikeDocument;
|
||||
document: Document;
|
||||
onRemove?: (id: string) => void;
|
||||
onClick?: (doc: MikeDocument) => void;
|
||||
onClick?: (doc: Document) => void;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -29,6 +29,7 @@ function formatBytes(bytes: number): string {
|
|||
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
|
||||
|
|
@ -52,8 +53,8 @@ export function DocumentCard({ document, onRemove, onClick, selected }: Props) {
|
|||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-gray-800" title={document.filename}>
|
||||
{document.filename}
|
||||
<p className="truncate font-medium text-gray-800" title={filename}>
|
||||
{filename}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{isProcessing
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { MikeIcon } from "@/components/chat/mike-icon";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useFetchDocxBytes } from "@/app/hooks/useFetchDocxBytes";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import {
|
||||
|
|
@ -50,6 +50,8 @@ interface Props {
|
|||
* 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").
|
||||
|
|
@ -201,6 +203,7 @@ export function DocxView({
|
|||
highlightEdit,
|
||||
refetchKey,
|
||||
quotes,
|
||||
quoteFocusKey,
|
||||
warning,
|
||||
onWarningDismiss,
|
||||
initialScrollTop,
|
||||
|
|
@ -347,13 +350,6 @@ export function DocxView({
|
|||
const scrollEl = scrollRef.current;
|
||||
const containerEl = containerRef.current;
|
||||
|
||||
console.log("[DocxView] render effect fired", {
|
||||
documentId,
|
||||
versionId,
|
||||
refetchKey,
|
||||
bytesLen: bytes.byteLength,
|
||||
});
|
||||
|
||||
// Remember scroll position across re-renders so Accept/Reject stays put.
|
||||
lastScrollTopRef.current = scrollEl.scrollTop;
|
||||
const thisRender = ++renderKeyRef.current;
|
||||
|
|
@ -447,7 +443,7 @@ export function DocxView({
|
|||
scrollRef.current,
|
||||
quotesRef.current,
|
||||
);
|
||||
}, [quoteKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [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
|
||||
|
|
@ -471,7 +467,7 @@ export function DocxView({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-col flex-1 overflow-hidden ${bordered ? "border border-gray-200" : ""} ${rounded ? "rounded-xl" : ""}`}
|
||||
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">
|
||||
|
|
@ -494,7 +490,7 @@ export function DocxView({
|
|||
>
|
||||
{loading && !bytes && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<MikeIcon spin mike size={28} />
|
||||
<Loader2 className="h-7 w-7 animate-spin text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
Trash2,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import type { MikeDocument, MikeProject } from "./types";
|
||||
import type { Document, Project } from "./types";
|
||||
import { VersionChip } from "./VersionChip";
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
|
|
@ -30,8 +30,8 @@ export function DocFileIcon({ fileType }: { fileType: string | null }) {
|
|||
}
|
||||
|
||||
interface FileDirectoryProps {
|
||||
standaloneDocs: MikeDocument[];
|
||||
directoryProjects: MikeProject[];
|
||||
standaloneDocs: Document[];
|
||||
directoryProjects: Project[];
|
||||
loading: boolean;
|
||||
selectedIds: Set<string>;
|
||||
onChange: (ids: Set<string>) => void;
|
||||
|
|
@ -238,7 +238,12 @@ export function FileDirectory({
|
|||
>
|
||||
{doc.filename}
|
||||
</span>
|
||||
<VersionChip n={doc.latest_version_number} />
|
||||
<VersionChip
|
||||
n={
|
||||
doc.active_version_number ??
|
||||
doc.latest_version_number
|
||||
}
|
||||
/>
|
||||
{doc.created_at && (
|
||||
<span className="shrink-0 text-gray-300">
|
||||
{formatDate(doc.created_at)}
|
||||
|
|
@ -333,7 +338,10 @@ export function FileDirectory({
|
|||
{doc.filename}
|
||||
</span>
|
||||
<VersionChip
|
||||
n={doc.latest_version_number}
|
||||
n={
|
||||
doc.active_version_number ??
|
||||
doc.latest_version_number
|
||||
}
|
||||
/>
|
||||
{doc.created_at && (
|
||||
<span className="shrink-0 text-gray-300">
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function HeaderSearchBtn({ value, onChange, placeholder = "Search…" }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
onChange("");
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open, onChange]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative flex items-center">
|
||||
{open ? (
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-2 bg-white border border-gray-200 rounded-lg px-3 py-1.5 shadow-sm z-10 w-72">
|
||||
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="flex-1 text-sm text-gray-700 placeholder:text-gray-400 outline-none bg-transparent"
|
||||
/>
|
||||
<button
|
||||
onClick={() => { setOpen(false); onChange(""); }}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex h-8 w-8 items-center justify-center text-gray-500 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
frontend/src/app/components/shared/Modal.tsx
Normal file
199
frontend/src/app/components/shared/Modal.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
"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;
|
||||
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,
|
||||
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-2xl shadow-2xl flex h-[600px] flex-col",
|
||||
sizeClassName[size],
|
||||
"border border-white/70 bg-white/80 shadow-[0_24px_80px_rgba(15,23,42,0.18)] 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-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>
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{icon}
|
||||
<h2 className="truncate text-base font-medium text-gray-900">
|
||||
{title}
|
||||
</h2>
|
||||
</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 pt-1 pb-2">
|
||||
{children}
|
||||
</div>
|
||||
{hasFooter && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-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 rounded-lg px-4 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40",
|
||||
variant === "primary" &&
|
||||
"bg-gray-900 text-white hover:bg-gray-700",
|
||||
variant === "secondary" && "text-gray-600 hover:bg-gray-100",
|
||||
fallbackVariant === "secondary" &&
|
||||
"border border-gray-200 hover:bg-gray-50",
|
||||
variant === "danger" &&
|
||||
"bg-red-600 text-white hover:bg-red-700",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import { Lock, X } from "lucide-react";
|
||||
import { Lock } from "lucide-react";
|
||||
import { WarningPopup } from "./WarningPopup";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
|
@ -38,56 +38,21 @@ export function OwnerOnlyModal({
|
|||
? `Only the project owner can ${action}.`
|
||||
: "Only the project owner can perform this action.");
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs"
|
||||
onClick={onClose}
|
||||
return (
|
||||
<WarningPopup
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
message={body}
|
||||
icon={<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-600" />}
|
||||
primaryAction={{ label: "OK", onClick: onClose }}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-2xl bg-white shadow-2xl flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 px-5 pt-5 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="h-4 w-4 text-amber-600" />
|
||||
<h2 className="text-base font-medium text-gray-900">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 pb-2 pt-1">
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
{body}
|
||||
</p>
|
||||
{ownerEmail && (
|
||||
<p className="mt-2 text-xs text-gray-400">
|
||||
Ask{" "}
|
||||
<span className="text-gray-600">{ownerEmail}</span>{" "}
|
||||
if you need access.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 px-5 pb-5 pt-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
{ownerEmail && (
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
Ask <span className="text-gray-600">{ownerEmail}</span> if
|
||||
you need access.
|
||||
</p>
|
||||
)}
|
||||
</WarningPopup>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
442
frontend/src/app/components/shared/PageHeader.tsx
Normal file
442
frontend/src/app/components/shared/PageHeader.tsx
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Fragment,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ButtonHTMLAttributes,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { ChevronLeft, Loader2, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface PageHeaderBreadcrumb {
|
||||
label?: ReactNode;
|
||||
suffix?: ReactNode;
|
||||
onClick?: () => void;
|
||||
loading?: boolean;
|
||||
skeletonClassName?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
type PageHeaderButtonAction = {
|
||||
type?: "button";
|
||||
icon?: ReactNode;
|
||||
label?: ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
variant?: "default" | "danger";
|
||||
iconOnly?: boolean;
|
||||
className?: string;
|
||||
tooltip?: ReactNode;
|
||||
};
|
||||
|
||||
type PageHeaderSearchAction = {
|
||||
type: "search";
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
type PageHeaderDeleteAction = {
|
||||
type: "delete";
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type PageHeaderNewAction = {
|
||||
type: "new";
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type PageHeaderCustomAction = {
|
||||
type: "custom";
|
||||
render: ReactNode;
|
||||
};
|
||||
|
||||
export type PageHeaderAction =
|
||||
| PageHeaderButtonAction
|
||||
| PageHeaderSearchAction
|
||||
| PageHeaderDeleteAction
|
||||
| PageHeaderNewAction
|
||||
| PageHeaderCustomAction
|
||||
| ReactNode;
|
||||
|
||||
interface PageHeaderProps {
|
||||
children?: ReactNode;
|
||||
actions?: PageHeaderAction[];
|
||||
actionGroups?: PageHeaderAction[][];
|
||||
align?: "center" | "start";
|
||||
shrink?: boolean;
|
||||
className?: string;
|
||||
actionGap?: "sm" | "md" | "lg";
|
||||
breadcrumbs?: PageHeaderBreadcrumb[];
|
||||
}
|
||||
|
||||
const actionGapClassName = {
|
||||
sm: "gap-2.5",
|
||||
md: "gap-2.5",
|
||||
lg: "gap-2.5",
|
||||
};
|
||||
|
||||
export function PageHeader({
|
||||
children,
|
||||
actions,
|
||||
actionGroups,
|
||||
align = "center",
|
||||
shrink = false,
|
||||
className,
|
||||
actionGap = "sm",
|
||||
breadcrumbs,
|
||||
}: PageHeaderProps) {
|
||||
const headerContent = breadcrumbs?.length ? (
|
||||
<PageHeaderBreadcrumbs items={breadcrumbs} />
|
||||
) : (
|
||||
children
|
||||
);
|
||||
const actionItems = actions?.filter(Boolean) ?? [];
|
||||
const groupedActionItems =
|
||||
actionGroups
|
||||
?.map((group) => group.filter(Boolean))
|
||||
.filter((group) => group.length > 0) ??
|
||||
(actionItems.length > 0 ? [actionItems] : []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-between",
|
||||
align === "start" ? "items-start" : "items-center",
|
||||
"px-4 md:px-10",
|
||||
"pb-4 pt-5.5",
|
||||
shrink && "shrink-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{headerContent}
|
||||
{groupedActionItems.length > 0 && (
|
||||
<div className="ml-4 flex shrink-0 items-center gap-3">
|
||||
{groupedActionItems.map((group, groupIndex) => (
|
||||
<div
|
||||
key={groupIndex}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center",
|
||||
actionGapClassName[actionGap],
|
||||
"rounded-full border border-white/70 bg-white px-1 py-1 shadow-[0_-1px_3px_rgba(15,23,42,0.03),0_2px_7px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.82),inset_0_-3px_7px_rgba(255,255,255,0.13)] backdrop-blur-2xl",
|
||||
)}
|
||||
>
|
||||
{group.map((action, index) => (
|
||||
<Fragment key={index}>
|
||||
<PageHeaderActionRenderer action={action} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageHeaderActionRenderer({ action }: { action: PageHeaderAction }) {
|
||||
if (!isPageHeaderActionObject(action)) return <>{action}</>;
|
||||
|
||||
switch (action.type) {
|
||||
case "search":
|
||||
return <PageHeaderSearchActionControl action={action} />;
|
||||
case "delete":
|
||||
return <PageHeaderDeleteActionControl action={action} />;
|
||||
case "new":
|
||||
return <PageHeaderNewActionControl action={action} />;
|
||||
case "custom":
|
||||
return <>{action.render}</>;
|
||||
case "button":
|
||||
default:
|
||||
return <PageHeaderButtonActionControl action={action} />;
|
||||
}
|
||||
}
|
||||
|
||||
function isPageHeaderActionObject(
|
||||
action: PageHeaderAction,
|
||||
): action is Exclude<PageHeaderAction, ReactNode> {
|
||||
return !!action && typeof action === "object" && !isValidElement(action);
|
||||
}
|
||||
|
||||
function PageHeaderButtonActionControl({
|
||||
action,
|
||||
}: {
|
||||
action: PageHeaderButtonAction;
|
||||
}) {
|
||||
const iconOnly = action.iconOnly ?? !action.label;
|
||||
return (
|
||||
<div className={action.tooltip ? "relative group" : undefined}>
|
||||
<PageHeaderActionButton
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
title={action.title}
|
||||
aria-label={action.title}
|
||||
variant={action.variant}
|
||||
iconOnly={iconOnly}
|
||||
className={action.className}
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</PageHeaderActionButton>
|
||||
{action.tooltip && (
|
||||
<div className="pointer-events-none absolute right-0 top-full mt-1.5 z-10 hidden items-center whitespace-nowrap rounded-lg bg-gray-900 px-2.5 py-1.5 text-xs text-white shadow-lg group-hover:flex">
|
||||
{action.tooltip}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageHeaderNewActionControl({
|
||||
action,
|
||||
}: {
|
||||
action: PageHeaderNewAction;
|
||||
}) {
|
||||
const title = action.title ?? "New";
|
||||
return (
|
||||
<PageHeaderActionButton
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled || action.loading}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
iconOnly
|
||||
>
|
||||
{action.loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
</PageHeaderActionButton>
|
||||
);
|
||||
}
|
||||
|
||||
function PageHeaderDeleteActionControl({
|
||||
action,
|
||||
}: {
|
||||
action: PageHeaderDeleteAction;
|
||||
}) {
|
||||
const title = action.title ?? "Delete";
|
||||
return (
|
||||
<PageHeaderActionButton
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled || action.loading}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
iconOnly
|
||||
variant="danger"
|
||||
>
|
||||
{action.loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</PageHeaderActionButton>
|
||||
);
|
||||
}
|
||||
|
||||
function PageHeaderSearchActionControl({
|
||||
action,
|
||||
}: {
|
||||
action: PageHeaderSearchAction;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const placeholder = action.placeholder ?? "Search…";
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
action.onChange("");
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open, action]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative flex items-center">
|
||||
{open ? (
|
||||
<div
|
||||
className={cn(
|
||||
pageHeaderActionControlClassName({
|
||||
className:
|
||||
"cursor-text justify-start gap-2 px-3 text-gray-700 hover:text-gray-700",
|
||||
}),
|
||||
"w-56 bg-gray-100 sm:w-80",
|
||||
)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={action.value}
|
||||
onChange={(e) => action.onChange(e.target.value)}
|
||||
className="flex-1 text-sm text-gray-700 placeholder:text-gray-400 outline-none bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<PageHeaderActionButton
|
||||
onClick={() => setOpen(true)}
|
||||
iconOnly
|
||||
title={placeholder}
|
||||
aria-label={placeholder}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</PageHeaderActionButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PageHeaderActionButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: "default" | "danger";
|
||||
iconOnly?: boolean;
|
||||
};
|
||||
|
||||
type PageHeaderActionControlClassNameOptions = {
|
||||
variant?: "default" | "danger";
|
||||
iconOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function pageHeaderActionControlClassName({
|
||||
variant = "default",
|
||||
iconOnly = false,
|
||||
disabled = false,
|
||||
className,
|
||||
}: PageHeaderActionControlClassNameOptions = {}) {
|
||||
return cn(
|
||||
"flex h-7 items-center justify-center rounded-full text-sm transition-colors hover:bg-gray-100 active:bg-gray-100 disabled:cursor-default disabled:text-gray-300 disabled:hover:bg-transparent disabled:hover:text-gray-300",
|
||||
iconOnly ? "w-7" : "gap-1.5 px-3",
|
||||
disabled ? "cursor-default" : "cursor-pointer",
|
||||
"hover:bg-gray-100 active:bg-gray-100",
|
||||
variant === "danger"
|
||||
? "text-gray-500 hover:text-red-600"
|
||||
: "text-gray-500 hover:text-gray-900",
|
||||
className,
|
||||
);
|
||||
}
|
||||
|
||||
function PageHeaderActionButton({
|
||||
children,
|
||||
className,
|
||||
variant = "default",
|
||||
iconOnly = false,
|
||||
disabled,
|
||||
...props
|
||||
}: PageHeaderActionButtonProps) {
|
||||
return (
|
||||
<button
|
||||
disabled={disabled}
|
||||
className={pageHeaderActionControlClassName({
|
||||
variant,
|
||||
iconOnly,
|
||||
disabled,
|
||||
className,
|
||||
})}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PageHeaderBreadcrumbs({ items }: { items: PageHeaderBreadcrumb[] }) {
|
||||
const current = items[items.length - 1];
|
||||
const parent = [...items]
|
||||
.slice(0, -1)
|
||||
.reverse()
|
||||
.find((item) => item.onClick);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-2xl font-medium font-serif">
|
||||
{parent?.onClick && (
|
||||
<button
|
||||
onClick={parent.onClick}
|
||||
className="shrink-0 text-gray-400 transition-colors hover:text-gray-600 sm:hidden"
|
||||
title={parent.title ?? "Back"}
|
||||
aria-label={parent.title ?? "Back"}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="hidden min-w-0 items-center gap-1.5 sm:flex">
|
||||
{items.map((item, index) => (
|
||||
<BreadcrumbItem
|
||||
key={index}
|
||||
item={item}
|
||||
current={index === items.length - 1}
|
||||
showSuffix
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-w-0 sm:hidden">
|
||||
{current ? (
|
||||
<BreadcrumbItem item={current} current showSuffix={false} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({
|
||||
item,
|
||||
current,
|
||||
showSuffix,
|
||||
}: {
|
||||
item: PageHeaderBreadcrumb;
|
||||
current: boolean;
|
||||
showSuffix: boolean;
|
||||
}) {
|
||||
const content = item.loading ? (
|
||||
<div
|
||||
className={cn(
|
||||
"h-6 rounded bg-gray-100 animate-pulse",
|
||||
item.skeletonClassName ?? "w-32",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="truncate">{item.label}</span>
|
||||
{showSuffix && item.suffix}
|
||||
</>
|
||||
);
|
||||
|
||||
const className = cn(
|
||||
"min-w-0 truncate transition-colors",
|
||||
current
|
||||
? "text-gray-900"
|
||||
: item.onClick
|
||||
? "text-gray-500 hover:text-gray-700"
|
||||
: "text-gray-500",
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{current ? (
|
||||
<span className={className}>{content}</span>
|
||||
) : item.onClick ? (
|
||||
<button onClick={item.onClick} className={className}>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<span className={className}>{content}</span>
|
||||
)}
|
||||
{!current && <span className="shrink-0 text-gray-300">›</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, User, UserPlus, Loader2, Plus } from "lucide-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
|
||||
|
|
@ -194,30 +194,22 @@ export function PeopleModal({
|
|||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs">
|
||||
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
{breadcrumb.map((segment, i) => (
|
||||
<span key={i} className="flex items-center gap-1.5">
|
||||
{i > 0 && <span>›</span>}
|
||||
{segment}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
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.`
|
||||
}
|
||||
>
|
||||
{/* Add-member row */}
|
||||
{onSharedWithChange && (
|
||||
<div className="px-4 pt-1 pb-2">
|
||||
<div className="pt-1 pb-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" />
|
||||
|
|
@ -281,7 +273,7 @@ export function PeopleModal({
|
|||
)}
|
||||
|
||||
{/* Section heading */}
|
||||
<div className="px-4 pt-3 pb-1 flex items-center gap-2">
|
||||
<div className="pt-3 pb-1 flex items-center gap-2">
|
||||
<h3 className="text-xs font-medium text-gray-500">
|
||||
People with Access
|
||||
</h3>
|
||||
|
|
@ -291,89 +283,77 @@ export function PeopleModal({
|
|||
</div>
|
||||
|
||||
{/* Member list */}
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-2">
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-3 text-[11px] text-gray-400">
|
||||
{roster.length === 0
|
||||
? "No one has access yet."
|
||||
: `${roster.length} ${
|
||||
roster.length === 1 ? "person" : "people"
|
||||
} with access.`}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export function PreResponseWrapper({
|
|||
const childrenGapClass = compact ? "gap-2.5" : "gap-4";
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg px-3 py-2">
|
||||
<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={() => {
|
||||
|
|
@ -61,7 +61,7 @@ export function PreResponseWrapper({
|
|||
</span>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className={`shrink-0 ml-2 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`}
|
||||
className={`relative top-px shrink-0 ml-2 transition-transform duration-200 ${isOpen ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
</button>
|
||||
{isOpen && (
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { Folder, Search, X } from "lucide-react";
|
||||
import type { MikeProject } from "./types";
|
||||
import type { Project } from "./types";
|
||||
|
||||
interface Props {
|
||||
projects: MikeProject[];
|
||||
projects: Project[];
|
||||
loading: boolean;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
|
|
@ -18,7 +18,7 @@ export function ProjectPicker({ projects, loading, selectedId, onSelect }: Props
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="px-4 pt-1 pb-2">
|
||||
<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
|
||||
|
|
@ -36,7 +36,7 @@ export function ProjectPicker({ projects, loading, selectedId, onSelect }: Props
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-2">
|
||||
<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">
|
||||
|
|
|
|||
297
frontend/src/app/components/shared/RelevantQuotes.tsx
Normal file
297
frontend/src/app/components/shared/RelevantQuotes.tsx
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,10 +11,11 @@ import {
|
|||
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
|
||||
import type { MikeChat } from "@/app/components/shared/types";
|
||||
import type { Chat } from "@/app/components/shared/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
chat: MikeChat;
|
||||
chat: Chat;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
projectName?: string;
|
||||
|
|
@ -48,9 +49,10 @@ export function SidebarChatItem({ chat, isActive, onSelect, projectName }: Props
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex items-center w-full h-9 rounded-md transition-colors ${
|
||||
isActive ? "bg-gray-100" : "hover:bg-gray-100"
|
||||
}`}
|
||||
className={cn(
|
||||
"group relative flex items-center w-full h-9 rounded-md transition-colors",
|
||||
isActive ? "bg-gray-200/60" : "hover:bg-gray-100",
|
||||
)}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<div className="flex items-center w-full px-2 py-1">
|
||||
|
|
@ -104,7 +106,7 @@ export function SidebarChatItem({ chat, isActive, onSelect, projectName }: Props
|
|||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={`p-1 mr-1 text-gray-500 transition-opacity hover:text-gray-900 ${
|
||||
className={`mr-1 rounded-md p-1 text-gray-500 transition-all hover:bg-gray-200 hover:text-gray-900 ${
|
||||
isActive
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100"
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, Upload } from "lucide-react";
|
||||
import { Upload } from "lucide-react";
|
||||
import { listDocumentVersions } from "@/app/lib/mikeApi";
|
||||
import type { MikeDocument } from "./types";
|
||||
import type { Document } from "./types";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
doc: MikeDocument | null;
|
||||
onSubmit: (file: File, displayName: string) => Promise<void>;
|
||||
doc: Document | null;
|
||||
onSubmit: (file: File, filename: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function UploadNewVersionModal({ open, onClose, doc, onSubmit }: Props) {
|
||||
|
|
@ -35,7 +35,7 @@ export function UploadNewVersionModal({ open, onClose, doc, onSubmit }: Props) {
|
|||
(v) => v.id === current_version_id,
|
||||
);
|
||||
const initial =
|
||||
(current?.display_name && current.display_name.trim()) ||
|
||||
(current?.filename && current.filename.trim()) ||
|
||||
doc.filename;
|
||||
if (!cancelled) {
|
||||
setName(initial);
|
||||
|
|
@ -72,87 +72,52 @@ export function UploadNewVersionModal({ open, onClose, doc, onSubmit }: Props) {
|
|||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/10 backdrop-blur-xs">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white shadow-2xl flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<div className="text-xs text-gray-400">
|
||||
Upload new version · {doc.filename}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Name input */}
|
||||
<div className="px-5 pb-4">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-gray-100 px-4 py-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
onChange={handleFilePick}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={submitting}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{stagedFile ? "Change file" : "Upload"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!stagedFile || submitting}
|
||||
className="rounded-lg bg-gray-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40"
|
||||
>
|
||||
{submitting ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
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>
|
||||
</div>,
|
||||
document.body,
|
||||
{stagedFile && (
|
||||
<div className="mt-2 text-xs text-gray-500 truncate">
|
||||
New Version File:{" "}
|
||||
<span className="text-gray-700">{stagedFile.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
108
frontend/src/app/components/shared/WarningPopup.tsx
Normal file
108
frontend/src/app/components/shared/WarningPopup.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"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;
|
||||
|
||||
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,
|
||||
)}
|
||||
>
|
||||
{icon ?? (
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-600" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1 self-center text-gray-900">
|
||||
{title && (
|
||||
<div className="font-medium text-gray-950">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{message && <div>{message}</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-gray-700 transition-colors hover:text-gray-950"
|
||||
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,4 +1,5 @@
|
|||
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();
|
||||
|
|
@ -23,6 +24,8 @@ function collectTextNodes(root: HTMLElement): Text[] {
|
|||
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;
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Shared TypeScript types for Mike AI legal assistant
|
||||
|
||||
export interface MikeFolder {
|
||||
export interface Folder {
|
||||
id: string;
|
||||
project_id: string;
|
||||
user_id: string;
|
||||
|
|
@ -10,7 +10,7 @@ export interface MikeFolder {
|
|||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MikeProject {
|
||||
export interface Project {
|
||||
id: string;
|
||||
user_id: string;
|
||||
is_owner?: boolean;
|
||||
|
|
@ -19,14 +19,14 @@ export interface MikeProject {
|
|||
shared_with: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
documents?: MikeDocument[];
|
||||
folders?: MikeFolder[];
|
||||
documents?: Document[];
|
||||
folders?: Folder[];
|
||||
document_count?: number;
|
||||
chat_count?: number;
|
||||
review_count?: number;
|
||||
}
|
||||
|
||||
export interface MikeDocument {
|
||||
export interface Document {
|
||||
id: string;
|
||||
user_id?: string;
|
||||
project_id: string | null;
|
||||
|
|
@ -41,7 +41,9 @@ export interface MikeDocument {
|
|||
status: "pending" | "processing" | "ready" | "error";
|
||||
created_at: string | null;
|
||||
updated_at?: string | null;
|
||||
/** Max version_number across assistant_edit rows, null if doc is unedited. */
|
||||
/** Version number of the document row pointed to by current_version_id. */
|
||||
active_version_number?: number | null;
|
||||
/** Legacy: max version_number across assistant_edit rows, null if doc is unedited. */
|
||||
latest_version_number?: number | null;
|
||||
}
|
||||
|
||||
|
|
@ -53,7 +55,7 @@ export interface StructureNode {
|
|||
children: StructureNode[];
|
||||
}
|
||||
|
||||
export interface MikeChat {
|
||||
export interface Chat {
|
||||
id: string;
|
||||
project_id: string | null;
|
||||
user_id: string;
|
||||
|
|
@ -61,7 +63,7 @@ export interface MikeChat {
|
|||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MikeEditAnnotation {
|
||||
export interface EditAnnotation {
|
||||
type?: "edit_data";
|
||||
kind?: "edit";
|
||||
edit_id: string;
|
||||
|
|
@ -82,161 +84,315 @@ export interface MikeEditAnnotation {
|
|||
|
||||
export type AssistantEvent =
|
||||
| { type: "reasoning"; text: string; isStreaming?: boolean }
|
||||
| { type: "error"; message: string }
|
||||
| {
|
||||
type: "tool_call_start";
|
||||
name: string;
|
||||
isStreaming?: boolean;
|
||||
type: "tool_call_start";
|
||||
name: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
| { type: "thinking"; isStreaming?: boolean }
|
||||
| {
|
||||
type: "doc_read";
|
||||
filename: string;
|
||||
document_id?: string;
|
||||
isStreaming?: boolean;
|
||||
type: "doc_read";
|
||||
filename: string;
|
||||
document_id?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "doc_find";
|
||||
filename: string;
|
||||
query: string;
|
||||
total_matches: number;
|
||||
isStreaming?: boolean;
|
||||
type: "doc_find";
|
||||
filename: string;
|
||||
query: string;
|
||||
total_matches: number;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "doc_created";
|
||||
filename: string;
|
||||
download_url: string;
|
||||
/** Set when the generated doc is persisted as a first-class document. */
|
||||
document_id?: string;
|
||||
version_id?: string;
|
||||
version_number?: number | null;
|
||||
isStreaming?: boolean;
|
||||
type: "doc_created";
|
||||
filename: string;
|
||||
download_url: string;
|
||||
/** Set when the generated doc is persisted as a first-class document. */
|
||||
document_id?: string;
|
||||
version_id?: string;
|
||||
version_number?: number | null;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
| { type: "doc_download"; filename: string; download_url: string }
|
||||
| {
|
||||
type: "doc_replicated";
|
||||
/** Source document filename. */
|
||||
filename: string;
|
||||
/** How many copies were produced in this single tool call. */
|
||||
count: number;
|
||||
/** One entry per new copy. Empty while streaming. */
|
||||
copies?: {
|
||||
new_filename: string;
|
||||
document_id: string;
|
||||
version_id: string;
|
||||
}[];
|
||||
error?: string;
|
||||
isStreaming?: boolean;
|
||||
type: "doc_replicated";
|
||||
/** Source document filename. */
|
||||
filename: string;
|
||||
/** How many copies were produced in this single tool call. */
|
||||
count: number;
|
||||
/** One entry per new copy. Empty while streaming. */
|
||||
copies?: {
|
||||
new_filename: string;
|
||||
document_id: string;
|
||||
version_id: string;
|
||||
}[];
|
||||
error?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
| { type: "workflow_applied"; workflow_id: string; title: string }
|
||||
| {
|
||||
type: "doc_edited";
|
||||
filename: string;
|
||||
document_id: string;
|
||||
version_id: string;
|
||||
/** Per-document monotonic Vn written at emit time. */
|
||||
version_number?: number | null;
|
||||
download_url: string;
|
||||
annotations: MikeEditAnnotation[];
|
||||
type: "doc_edited";
|
||||
filename: string;
|
||||
document_id: string;
|
||||
version_id: string;
|
||||
/** Per-document monotonic Vn written at emit time. */
|
||||
version_number?: number | null;
|
||||
download_url: string;
|
||||
annotations: EditAnnotation[];
|
||||
error?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "courtlistener_search_case_law";
|
||||
query: string;
|
||||
result_count?: number;
|
||||
error?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "courtlistener_get_cases";
|
||||
cluster_ids: number[];
|
||||
case_count?: number;
|
||||
opinion_count?: number;
|
||||
cases?: {
|
||||
cluster_id: number;
|
||||
case_name: string | null;
|
||||
citation: string | null;
|
||||
dateFiled?: string | null;
|
||||
url?: string | null;
|
||||
}[];
|
||||
error?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "courtlistener_find_in_case";
|
||||
cluster_id: number | null;
|
||||
query: string;
|
||||
total_matches?: number;
|
||||
case_name?: string | null;
|
||||
citation?: string | null;
|
||||
searches?: {
|
||||
cluster_id: number | null;
|
||||
query: string;
|
||||
total_matches?: number;
|
||||
case_name?: string | null;
|
||||
citation?: string | null;
|
||||
error?: string;
|
||||
isStreaming?: boolean;
|
||||
}[];
|
||||
error?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "courtlistener_read_case";
|
||||
cluster_id: number | null;
|
||||
case_name?: string | null;
|
||||
citation?: string | null;
|
||||
opinion_count?: number;
|
||||
error?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "courtlistener_verify_citations";
|
||||
citation_count?: number;
|
||||
match_count?: number;
|
||||
error?: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "case_citation";
|
||||
cluster_id: number | null;
|
||||
case_name: string | null;
|
||||
citation: string | null;
|
||||
url: string;
|
||||
pdfUrl?: string | null;
|
||||
dateFiled?: string | null;
|
||||
judges?: string | null;
|
||||
case?: Extract<AssistantEvent, { type: "case_opinions" }>["case"];
|
||||
}
|
||||
| {
|
||||
type: "case_opinions";
|
||||
cluster_id: number;
|
||||
case: {
|
||||
id: number | null;
|
||||
caseName?: string | null;
|
||||
dateFiled?: string | null;
|
||||
citations?: string[];
|
||||
url?: string | null;
|
||||
pdfUrl?: string | null;
|
||||
opinions: {
|
||||
opinionId: number | null;
|
||||
apiUrl?: string | null;
|
||||
type: string | null;
|
||||
author: string | null;
|
||||
url: string | null;
|
||||
text?: string | null;
|
||||
html?: string | null;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
| { type: "content"; text: string; isStreaming?: boolean };
|
||||
|
||||
export interface MikeMessage {
|
||||
export type CaseCitationQuote = {
|
||||
opinionId: number | null;
|
||||
type: string | null;
|
||||
author: string | null;
|
||||
quote: string;
|
||||
};
|
||||
|
||||
export interface Message {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
files?: { filename: string; document_id?: string }[];
|
||||
workflow?: { id: string; title: string };
|
||||
model?: string;
|
||||
annotations?: MikeCitationAnnotation[];
|
||||
annotations?: CitationAnnotation[];
|
||||
citationStatus?: "started" | "partial" | "final";
|
||||
events?: AssistantEvent[];
|
||||
/** Set when streaming failed; rendered as a red error block. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CitationQuote {
|
||||
page: number;
|
||||
page?: number;
|
||||
quote: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A citation emitted by the assistant. Single-page citations have a numeric
|
||||
* `page` and a plain `quote`. A citation that spans a page break (one
|
||||
* continuous sentence cut by a page boundary) has `page` as a range string
|
||||
* like "41-42" and a `quote` containing the `[[PAGE_BREAK]]` sentinel at the
|
||||
* break point (text before is on page 41, text after is on page 42).
|
||||
*/
|
||||
export interface MikeCitationAnnotation {
|
||||
export type DocumentCitationQuote = {
|
||||
page: number | string;
|
||||
quote: string;
|
||||
};
|
||||
|
||||
export type DocumentCitationAnnotation = {
|
||||
type: "citation_data";
|
||||
kind?: "document";
|
||||
ref: number;
|
||||
doc_id: string;
|
||||
document_id: string;
|
||||
version_id?: string | null;
|
||||
version_number?: number | null;
|
||||
filename: string;
|
||||
/** Legacy single-quote fields. Prefer `quotes` for new annotations. */
|
||||
page: number | string;
|
||||
quote: string;
|
||||
}
|
||||
quotes?: DocumentCitationQuote[];
|
||||
};
|
||||
|
||||
export type CaseCitationAnnotation = {
|
||||
type: "citation_data";
|
||||
kind: "case";
|
||||
ref: number;
|
||||
cluster_id: number;
|
||||
case_name?: string | null;
|
||||
citation?: string | null;
|
||||
url?: string | null;
|
||||
pdfUrl?: string | null;
|
||||
dateFiled?: string | null;
|
||||
judges?: string | null;
|
||||
quotes: CaseCitationQuote[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A citation emitted by the assistant. Document citations have doc/page
|
||||
* anchors. Case citations anchor to a CourtListener cluster and include a
|
||||
* quoted opinion passage.
|
||||
*/
|
||||
export type CitationAnnotation =
|
||||
| DocumentCitationAnnotation
|
||||
| CaseCitationAnnotation;
|
||||
|
||||
const PAGE_BREAK_SENTINEL = "[[PAGE_BREAK]]";
|
||||
|
||||
function expandDocumentQuoteEntry(entry: DocumentCitationQuote): CitationQuote[] {
|
||||
const rangeMatch =
|
||||
typeof entry.page === "string"
|
||||
? entry.page.match(/^(\d+)\s*-\s*(\d+)$/)
|
||||
: null;
|
||||
if (rangeMatch && entry.quote.includes(PAGE_BREAK_SENTINEL)) {
|
||||
const startPage = parseInt(rangeMatch[1], 10);
|
||||
const endPage = parseInt(rangeMatch[2], 10);
|
||||
const [before, after] = entry.quote.split(PAGE_BREAK_SENTINEL);
|
||||
return [
|
||||
{ page: startPage, quote: before.trim() },
|
||||
{ page: endPage, quote: after.trim() },
|
||||
].filter((e) => e.quote.length > 0);
|
||||
}
|
||||
const pageNum =
|
||||
typeof entry.page === "number"
|
||||
? entry.page
|
||||
: parseInt(String(entry.page), 10);
|
||||
if (!Number.isFinite(pageNum)) return [];
|
||||
return [{ page: pageNum, quote: entry.quote }];
|
||||
}
|
||||
|
||||
export function getDocumentCitationQuotes(
|
||||
a: CitationAnnotation,
|
||||
): 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 }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a citation into one or more (page, quote) entries suitable for
|
||||
* highlighting in the PDF viewer. A single-page citation yields one entry; a
|
||||
* cross-page citation with page "N-M" and a `[[PAGE_BREAK]]` split yields two.
|
||||
*/
|
||||
export function expandCitationToEntries(
|
||||
a: MikeCitationAnnotation,
|
||||
a: CitationAnnotation,
|
||||
): CitationQuote[] {
|
||||
const rangeMatch =
|
||||
typeof a.page === "string"
|
||||
? a.page.match(/^(\d+)\s*-\s*(\d+)$/)
|
||||
: null;
|
||||
if (rangeMatch && a.quote.includes(PAGE_BREAK_SENTINEL)) {
|
||||
const startPage = parseInt(rangeMatch[1], 10);
|
||||
const endPage = parseInt(rangeMatch[2], 10);
|
||||
const [before, after] = a.quote.split(PAGE_BREAK_SENTINEL);
|
||||
return [
|
||||
{ page: startPage, quote: before.trim() },
|
||||
{ page: endPage, quote: after.trim() },
|
||||
].filter((e) => e.quote.length > 0);
|
||||
}
|
||||
const pageNum =
|
||||
typeof a.page === "number" ? a.page : parseInt(String(a.page), 10);
|
||||
if (!Number.isFinite(pageNum)) return [];
|
||||
return [{ page: pageNum, quote: a.quote }];
|
||||
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: MikeCitationAnnotation): string {
|
||||
export function formatCitationPage(a: CitationAnnotation): string {
|
||||
if (a.kind === "case") {
|
||||
return a.citation || a.case_name || `Case ${a.cluster_id}`;
|
||||
}
|
||||
const quotes = getDocumentCitationQuotes(a);
|
||||
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}`;
|
||||
}
|
||||
|
||||
/** Produce a reader-friendly version of the quote (replaces [[PAGE_BREAK]] with "..."). */
|
||||
export function displayCitationQuote(a: MikeCitationAnnotation): string {
|
||||
return a.quote.replaceAll(PAGE_BREAK_SENTINEL, "...");
|
||||
export function displayCitationQuote(a: CitationAnnotation): 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, "..."))
|
||||
.join(" / ");
|
||||
}
|
||||
|
||||
// Tabular Review
|
||||
|
||||
export type ColumnFormat =
|
||||
| "text"
|
||||
| "bulleted_list"
|
||||
| "number"
|
||||
| "currency"
|
||||
| "yes_no"
|
||||
| "date"
|
||||
| "tag"
|
||||
| "percentage"
|
||||
| "monetary_amount";
|
||||
| "text"
|
||||
| "bulleted_list"
|
||||
| "number"
|
||||
| "currency"
|
||||
| "yes_no"
|
||||
| "date"
|
||||
| "tag"
|
||||
| "percentage"
|
||||
| "monetary_amount";
|
||||
|
||||
export interface ColumnConfig {
|
||||
index: number;
|
||||
name: string;
|
||||
prompt: string;
|
||||
format?: ColumnFormat;
|
||||
tags?: string[];
|
||||
index: number;
|
||||
name: string;
|
||||
prompt: string;
|
||||
format?: ColumnFormat;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface TabularReview {
|
||||
|
|
@ -273,7 +429,7 @@ export interface TabularCell {
|
|||
|
||||
// Workflows
|
||||
|
||||
export interface MikeWorkflow {
|
||||
export interface Workflow {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
title: string;
|
||||
|
|
@ -290,13 +446,13 @@ export interface MikeWorkflow {
|
|||
|
||||
// API helpers
|
||||
|
||||
export interface MikeChatDetailOut {
|
||||
chat: MikeChat;
|
||||
messages: MikeMessage[];
|
||||
export interface ChatDetailOut {
|
||||
chat: Chat;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export interface TabularReviewDetailOut {
|
||||
review: TabularReview;
|
||||
cells: TabularCell[];
|
||||
documents: MikeDocument[];
|
||||
documents: Document[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getProject, listProjects, listStandaloneDocuments } from "@/app/lib/mikeApi";
|
||||
import type { MikeDocument, MikeProject } from "./types";
|
||||
import type { Document, Project } from "./types";
|
||||
|
||||
const CACHE_TTL_MS = 30_000;
|
||||
|
||||
interface DirectoryCache {
|
||||
standaloneDocuments: MikeDocument[];
|
||||
projects: MikeProject[];
|
||||
standaloneDocuments: Document[];
|
||||
projects: Project[];
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
|
|
@ -20,8 +20,8 @@ export function invalidateDirectoryCache() {
|
|||
|
||||
export function useDirectoryData(enabled: boolean) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [standaloneDocuments, setStandaloneDocuments] = useState<MikeDocument[]>([]);
|
||||
const [projects, setProjects] = useState<MikeProject[]>([]);
|
||||
const [standaloneDocuments, setStandaloneDocuments] = useState<Document[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check, ChevronDown, Loader2, Upload, X } from "lucide-react";
|
||||
import type { MikeDocument, MikeProject, MikeWorkflow } from "../shared/types";
|
||||
import { Check, ChevronDown, Loader2, Upload } from "lucide-react";
|
||||
import type { Document, Project, Workflow } from "../shared/types";
|
||||
import {
|
||||
getProject,
|
||||
listProjects,
|
||||
|
|
@ -14,6 +13,7 @@ import {
|
|||
} 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;
|
||||
|
|
@ -22,11 +22,11 @@ interface Props {
|
|||
title: string,
|
||||
projectId?: string,
|
||||
documentIds?: string[],
|
||||
columnsConfig?: MikeWorkflow["columns_config"],
|
||||
columnsConfig?: Workflow["columns_config"],
|
||||
) => void;
|
||||
projects?: MikeProject[];
|
||||
projects?: Project[];
|
||||
/** When provided, skip the project/directory picker and show only these docs */
|
||||
projectDocs?: MikeDocument[];
|
||||
projectDocs?: Document[];
|
||||
projectName?: string;
|
||||
projectCmNumber?: string | null;
|
||||
}
|
||||
|
|
@ -47,12 +47,12 @@ export function AddNewTRModal({
|
|||
const [projectDropdownOpen, setProjectDropdownOpen] = useState(false);
|
||||
|
||||
// Project-scoped docs (when underProject is true and no fixedProjectDocs)
|
||||
const [projectDocs, setProjectDocs] = useState<MikeDocument[]>([]);
|
||||
const [projectDocs, setProjectDocs] = useState<Document[]>([]);
|
||||
const [loadingDocs, setLoadingDocs] = useState(false);
|
||||
|
||||
// Full directory (when underProject is false)
|
||||
const [standaloneDocs, setStandaloneDocs] = useState<MikeDocument[]>([]);
|
||||
const [directoryProjects, setDirectoryProjects] = useState<MikeProject[]>(
|
||||
const [standaloneDocs, setStandaloneDocs] = useState<Document[]>([]);
|
||||
const [directoryProjects, setDirectoryProjects] = useState<Project[]>(
|
||||
[],
|
||||
);
|
||||
const [loadingDirectory, setLoadingDirectory] = useState(false);
|
||||
|
|
@ -64,12 +64,13 @@ export function AddNewTRModal({
|
|||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Workflow templates
|
||||
const [workflows, setWorkflows] = useState<MikeWorkflow[]>([]);
|
||||
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;
|
||||
|
|
@ -205,7 +206,7 @@ export function AddNewTRModal({
|
|||
: underProject
|
||||
? []
|
||||
: directoryProjects;
|
||||
const flatProjectDocs: MikeDocument[] =
|
||||
const flatProjectDocs: Document[] =
|
||||
!isProjectMode && underProject ? projectDocs : [];
|
||||
const directoryLoading = isProjectMode
|
||||
? false
|
||||
|
|
@ -213,56 +214,59 @@ export function AddNewTRModal({
|
|||
? loadingDocs
|
||||
: loadingDirectory;
|
||||
const showDirectory = isProjectMode || !underProject || !!selectedProjectId;
|
||||
const breadcrumbs =
|
||||
isProjectMode && projectName
|
||||
? [
|
||||
"Projects",
|
||||
`${projectName}${projectCmNumber ? ` (#${projectCmNumber})` : ""}`,
|
||||
"New Tabular Review",
|
||||
]
|
||||
: ["Tabular Reviews", "New Tabular Review"];
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[101] flex items-center justify-center bg-black/20 backdrop-blur-xs">
|
||||
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 pt-5 pb-2 shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
{isProjectMode && projectName ? (
|
||||
<>
|
||||
<span>Projects</span>
|
||||
<span>›</span>
|
||||
<span>
|
||||
{projectName}
|
||||
{projectCmNumber ? ` (#${projectCmNumber})` : ""}
|
||||
</span>
|
||||
<span>›</span>
|
||||
<span>Tabular Reviews</span>
|
||||
<span>›</span>
|
||||
<span>New review</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Tabular Reviews</span>
|
||||
<span>›</span>
|
||||
<span>New review</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col min-h-0 flex-1"
|
||||
>
|
||||
<div className="px-6 pt-3 pb-4 space-y-5 overflow-y-auto flex-1">
|
||||
{/* Title */}
|
||||
<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
|
||||
/>
|
||||
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">
|
||||
|
|
@ -477,56 +481,8 @@ export function AddNewTRModal({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between gap-2 border-t border-gray-100 px-6 py-4 shrink-0">
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.docx,.doc"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{uploading ? "Uploading…" : "Upload"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="rounded-lg px-4 py-2 text-sm text-gray-500 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
!title.trim() ||
|
||||
(underProject && !selectedProjectId)
|
||||
}
|
||||
className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ import { useEffect, useRef, useState } from "react";
|
|||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
X,
|
||||
Clock,
|
||||
MessageSquarePlus,
|
||||
Search,
|
||||
Square,
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { MikeIcon } from "@/components/chat/mike-icon";
|
||||
|
|
@ -23,11 +23,7 @@ import {
|
|||
type TRChat,
|
||||
type TRCitationAnnotation,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import type {
|
||||
AssistantEvent,
|
||||
ColumnConfig,
|
||||
MikeDocument,
|
||||
} from "../shared/types";
|
||||
import type { AssistantEvent, ColumnConfig, Document } from "../shared/types";
|
||||
import { ModelToggle } from "../assistant/ModelToggle";
|
||||
import { ApiKeyMissingModal } from "../shared/ApiKeyMissingModal";
|
||||
import { PreResponseWrapper } from "../shared/PreResponseWrapper";
|
||||
|
|
@ -38,6 +34,7 @@ import {
|
|||
type ModelProvider,
|
||||
} from "@/app/lib/modelAvailability";
|
||||
import type { ApiKeyState } from "@/app/lib/mikeApi";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
|
|
@ -51,12 +48,64 @@ interface TRMessage {
|
|||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
function parseCourtlistenerEventCases(value: unknown) {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
return value
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
||||
return null;
|
||||
}
|
||||
const row = item as Record<string, unknown>;
|
||||
return {
|
||||
cluster_id:
|
||||
typeof row.cluster_id === "number" ? row.cluster_id : 0,
|
||||
case_name:
|
||||
typeof row.case_name === "string" ? row.case_name : null,
|
||||
citation:
|
||||
typeof row.citation === "string" ? row.citation : null,
|
||||
dateFiled:
|
||||
typeof row.dateFiled === "string" ? row.dateFiled : null,
|
||||
url: typeof row.url === "string" ? row.url : null,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(item): item is NonNullable<typeof item> =>
|
||||
!!item && item.cluster_id > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function parseCourtlistenerCaseSearches(value: unknown) {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
return value
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
||||
return null;
|
||||
}
|
||||
const row = item as Record<string, unknown>;
|
||||
return {
|
||||
cluster_id:
|
||||
typeof row.cluster_id === "number" ? row.cluster_id : null,
|
||||
query: typeof row.query === "string" ? row.query : "",
|
||||
total_matches:
|
||||
typeof row.total_matches === "number"
|
||||
? row.total_matches
|
||||
: 0,
|
||||
case_name:
|
||||
typeof row.case_name === "string" ? row.case_name : null,
|
||||
citation:
|
||||
typeof row.citation === "string" ? row.citation : null,
|
||||
error: typeof row.error === "string" ? row.error : undefined,
|
||||
};
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => !!item);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
reviewId: string;
|
||||
reviewTitle?: string | null;
|
||||
projectName?: string | null;
|
||||
columns: ColumnConfig[];
|
||||
documents: MikeDocument[];
|
||||
documents: Document[];
|
||||
onCitationClick: (colIdx: number, rowIdx: number) => void;
|
||||
onClose: () => void;
|
||||
initialChatId?: string | null;
|
||||
|
|
@ -73,6 +122,8 @@ const THINKING_PHRASES = [
|
|||
"Analyzing...",
|
||||
"Reasoning...",
|
||||
];
|
||||
const REASONING_COLLAPSED_MAX_LINES = 6;
|
||||
const REASONING_COLLAPSED_MAX_HEIGHT_REM = 9;
|
||||
|
||||
function ReasoningBlock({
|
||||
text,
|
||||
|
|
@ -82,7 +133,11 @@ function ReasoningBlock({
|
|||
isStreaming: boolean;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [userToggled, setUserToggled] = useState(false);
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
const [hasMeasured, setHasMeasured] = useState(false);
|
||||
const [phraseIdx, setPhraseIdx] = useState(0);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStreaming) return;
|
||||
|
|
@ -93,10 +148,28 @@ function ReasoningBlock({
|
|||
return () => clearInterval(interval);
|
||||
}, [isStreaming]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 24;
|
||||
const maxHeight = lineHeight * REASONING_COLLAPSED_MAX_LINES;
|
||||
const nextOverflowing = el.scrollHeight > maxHeight + 2;
|
||||
setIsOverflowing(nextOverflowing);
|
||||
setHasMeasured(true);
|
||||
if (nextOverflowing && !userToggled) setIsOpen(false);
|
||||
}, [text, userToggled]);
|
||||
|
||||
const showContent = isOpen || isStreaming || isOverflowing || !hasMeasured;
|
||||
const isCollapsed = isOverflowing && !isOpen;
|
||||
|
||||
return (
|
||||
<div className="ml-1">
|
||||
<button
|
||||
onClick={() => !isStreaming && setIsOpen((v) => !v)}
|
||||
onClick={() => {
|
||||
if (isStreaming) return;
|
||||
setUserToggled(true);
|
||||
setIsOpen((v) => !v);
|
||||
}}
|
||||
className="flex items-center text-sm text-gray-400 hover:text-gray-500 transition-colors"
|
||||
>
|
||||
{isStreaming ? (
|
||||
|
|
@ -116,11 +189,56 @@ function ReasoningBlock({
|
|||
/>
|
||||
)}
|
||||
</button>
|
||||
{(isOpen || isStreaming) && (
|
||||
<div className="mt-1.5 ml-[14px] text-sm text-gray-400 prose prose-sm max-w-none [&>*]:text-gray-400 [&>*]:text-sm">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
{showContent && (
|
||||
<div className="mt-1.5 ml-[14px]">
|
||||
<div
|
||||
className={`relative ${isCollapsed ? "overflow-hidden" : ""}`}
|
||||
style={
|
||||
isCollapsed
|
||||
? {
|
||||
maxHeight: `${REASONING_COLLAPSED_MAX_HEIGHT_REM}rem`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="text-sm text-gray-400 prose prose-sm max-w-none [&>*]:text-gray-400 [&>*]:text-sm"
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
{isCollapsed && (
|
||||
<>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-b from-white/0 to-white" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUserToggled(true);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
className="absolute left-1/2 bottom-2 z-10 -translate-x-1/2 text-gray-400 transition-colors hover:text-gray-600"
|
||||
aria-label="Expand thought process"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isOverflowing && isOpen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUserToggled(true);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="mx-auto mt-2 flex text-gray-400 transition-colors hover:text-gray-600"
|
||||
aria-label="Minimise thought process"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5 rotate-180" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -507,9 +625,17 @@ function TRChatInput({
|
|||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="absolute bottom-0 left-0 right-0 px-4 pb-4 bg-white"
|
||||
className={cn(
|
||||
"absolute bottom-0 left-0 right-0 px-4 pb-3",
|
||||
"bg-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="border border-gray-300 rounded-xl bg-white pt-2 pb-1.5 flex flex-col gap-1">
|
||||
<div
|
||||
className={cn(
|
||||
"pt-2 pb-1.5 flex flex-col gap-1",
|
||||
"rounded-[18px] border border-white/65 bg-white/60 shadow-[0_6px_18px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.85),inset_0_-6px_14px_rgba(255,255,255,0.18)] backdrop-blur-2xl",
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
|
|
@ -537,7 +663,10 @@ function TRChatInput({
|
|||
type="button"
|
||||
onClick={handleAction}
|
||||
disabled={!isLoading && !value.trim()}
|
||||
className="relative bg-gradient-to-b from-neutral-700 to-black text-white rounded-[10px] h-7 w-7 shrink-0 flex items-center justify-center disabled:cursor-default disabled:from-neutral-600 disabled:to-black border border-white/30 active:enabled:scale-95 transition-all duration-150"
|
||||
className={cn(
|
||||
"relative bg-gradient-to-b from-neutral-700 to-black text-white rounded-[10px] h-7 w-7 shrink-0 flex items-center justify-center disabled:cursor-default disabled:from-neutral-600 disabled:to-black border border-white/30 active:enabled:scale-95 transition-all duration-150",
|
||||
"shadow-[0_5px_14px_rgba(15,23,42,0.18),inset_0_1px_0_rgba(255,255,255,0.24)]",
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Square
|
||||
|
|
@ -930,7 +1059,7 @@ export function TRChatPanel({
|
|||
.map((_, i) => i)
|
||||
.reverse()
|
||||
.find((i) => predicate(events[i]));
|
||||
if (idx === undefined) return;
|
||||
if (idx === undefined) return false;
|
||||
const newEvents = [...events];
|
||||
newEvents[idx] = updater(events[idx]);
|
||||
eventsRef.current = newEvents;
|
||||
|
|
@ -943,6 +1072,7 @@ export function TRChatPanel({
|
|||
}
|
||||
return updated;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- chat actions ----
|
||||
|
|
@ -1225,6 +1355,295 @@ export function TRChatPanel({
|
|||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
data.type === "courtlistener_search_case_law_start"
|
||||
) {
|
||||
pushEvent({
|
||||
type: "courtlistener_search_case_law",
|
||||
query: (data.query as string) ?? "",
|
||||
isStreaming: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.type === "courtlistener_search_case_law") {
|
||||
updateMatchingEvent(
|
||||
(e) =>
|
||||
e.type ===
|
||||
"courtlistener_search_case_law" &&
|
||||
e.query === (data.query as string) &&
|
||||
!!e.isStreaming,
|
||||
() => ({
|
||||
type: "courtlistener_search_case_law",
|
||||
query: (data.query as string) ?? "",
|
||||
result_count:
|
||||
typeof data.result_count === "number"
|
||||
? (data.result_count as number)
|
||||
: 0,
|
||||
error:
|
||||
typeof data.error === "string"
|
||||
? (data.error as string)
|
||||
: undefined,
|
||||
isStreaming: false,
|
||||
}),
|
||||
);
|
||||
pushThinkingPlaceholder();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.type === "courtlistener_get_cases_start") {
|
||||
pushEvent({
|
||||
type: "courtlistener_get_cases",
|
||||
cluster_ids: Array.isArray(data.cluster_ids)
|
||||
? (data.cluster_ids as unknown[]).filter(
|
||||
(value: unknown): value is number =>
|
||||
typeof value === "number",
|
||||
)
|
||||
: [],
|
||||
isStreaming: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.type === "courtlistener_get_cases") {
|
||||
updateMatchingEvent(
|
||||
(e) =>
|
||||
e.type === "courtlistener_get_cases" &&
|
||||
!!e.isStreaming,
|
||||
() => ({
|
||||
type: "courtlistener_get_cases",
|
||||
cluster_ids: Array.isArray(data.cluster_ids)
|
||||
? (
|
||||
data.cluster_ids as unknown[]
|
||||
).filter(
|
||||
(
|
||||
value: unknown,
|
||||
): value is number =>
|
||||
typeof value === "number",
|
||||
)
|
||||
: [],
|
||||
case_count:
|
||||
typeof data.case_count === "number"
|
||||
? (data.case_count as number)
|
||||
: 0,
|
||||
opinion_count:
|
||||
typeof data.opinion_count === "number"
|
||||
? (data.opinion_count as number)
|
||||
: 0,
|
||||
cases: parseCourtlistenerEventCases(
|
||||
data.cases,
|
||||
),
|
||||
error:
|
||||
typeof data.error === "string"
|
||||
? (data.error as string)
|
||||
: undefined,
|
||||
isStreaming: false,
|
||||
}),
|
||||
);
|
||||
pushThinkingPlaceholder();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
data.type === "courtlistener_find_in_case_start"
|
||||
) {
|
||||
const searches = parseCourtlistenerCaseSearches(
|
||||
data.searches,
|
||||
);
|
||||
pushEvent({
|
||||
type: "courtlistener_find_in_case",
|
||||
cluster_id: searches?.length
|
||||
? null
|
||||
: typeof data.cluster_id === "number"
|
||||
? (data.cluster_id as number)
|
||||
: null,
|
||||
query: searches?.length
|
||||
? ""
|
||||
: ((data.query as string) ?? ""),
|
||||
searches,
|
||||
isStreaming: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.type === "courtlistener_find_in_case") {
|
||||
const searches = parseCourtlistenerCaseSearches(
|
||||
data.searches,
|
||||
);
|
||||
updateMatchingEvent(
|
||||
(e) =>
|
||||
e.type ===
|
||||
"courtlistener_find_in_case" &&
|
||||
(searches?.length
|
||||
? Array.isArray(e.searches)
|
||||
: e.cluster_id ===
|
||||
(typeof data.cluster_id ===
|
||||
"number"
|
||||
? (data.cluster_id as number)
|
||||
: null) &&
|
||||
e.query ===
|
||||
(data.query as string)) &&
|
||||
!!e.isStreaming,
|
||||
() => ({
|
||||
type: "courtlistener_find_in_case",
|
||||
cluster_id: searches?.length
|
||||
? null
|
||||
: typeof data.cluster_id === "number"
|
||||
? (data.cluster_id as number)
|
||||
: null,
|
||||
query: searches?.length
|
||||
? ""
|
||||
: ((data.query as string) ?? ""),
|
||||
total_matches:
|
||||
typeof data.total_matches === "number"
|
||||
? (data.total_matches as number)
|
||||
: 0,
|
||||
searches,
|
||||
case_name:
|
||||
typeof data.case_name === "string"
|
||||
? (data.case_name as string)
|
||||
: null,
|
||||
citation:
|
||||
typeof data.citation === "string"
|
||||
? (data.citation as string)
|
||||
: null,
|
||||
error:
|
||||
typeof data.error === "string"
|
||||
? (data.error as string)
|
||||
: undefined,
|
||||
isStreaming: false,
|
||||
}),
|
||||
);
|
||||
pushThinkingPlaceholder();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.type === "courtlistener_read_case_start") {
|
||||
pushEvent({
|
||||
type: "courtlistener_read_case",
|
||||
cluster_id:
|
||||
typeof data.cluster_id === "number"
|
||||
? (data.cluster_id as number)
|
||||
: null,
|
||||
isStreaming: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.type === "courtlistener_read_case") {
|
||||
updateMatchingEvent(
|
||||
(e) =>
|
||||
e.type === "courtlistener_read_case" &&
|
||||
e.cluster_id ===
|
||||
(typeof data.cluster_id === "number"
|
||||
? (data.cluster_id as number)
|
||||
: null) &&
|
||||
!!e.isStreaming,
|
||||
() => ({
|
||||
type: "courtlistener_read_case",
|
||||
cluster_id:
|
||||
typeof data.cluster_id === "number"
|
||||
? (data.cluster_id as number)
|
||||
: null,
|
||||
case_name:
|
||||
typeof data.case_name === "string"
|
||||
? (data.case_name as string)
|
||||
: null,
|
||||
citation:
|
||||
typeof data.citation === "string"
|
||||
? (data.citation as string)
|
||||
: null,
|
||||
opinion_count:
|
||||
typeof data.opinion_count === "number"
|
||||
? (data.opinion_count as number)
|
||||
: 0,
|
||||
error:
|
||||
typeof data.error === "string"
|
||||
? (data.error as string)
|
||||
: undefined,
|
||||
isStreaming: false,
|
||||
}),
|
||||
);
|
||||
pushThinkingPlaceholder();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
data.type === "courtlistener_verify_citations_start"
|
||||
) {
|
||||
pushEvent({
|
||||
type: "courtlistener_verify_citations",
|
||||
citation_count:
|
||||
typeof data.citation_count === "number"
|
||||
? (data.citation_count as number)
|
||||
: 0,
|
||||
isStreaming: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.type === "courtlistener_verify_citations") {
|
||||
updateMatchingEvent(
|
||||
(e) =>
|
||||
e.type ===
|
||||
"courtlistener_verify_citations" &&
|
||||
!!e.isStreaming,
|
||||
() => ({
|
||||
type: "courtlistener_verify_citations",
|
||||
citation_count:
|
||||
typeof data.citation_count === "number"
|
||||
? (data.citation_count as number)
|
||||
: 0,
|
||||
match_count:
|
||||
typeof data.match_count === "number"
|
||||
? (data.match_count as number)
|
||||
: 0,
|
||||
error:
|
||||
typeof data.error === "string"
|
||||
? (data.error as string)
|
||||
: undefined,
|
||||
isStreaming: false,
|
||||
}),
|
||||
);
|
||||
pushThinkingPlaceholder();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.type === "case_citation") {
|
||||
pushEvent({
|
||||
type: "case_citation",
|
||||
cluster_id:
|
||||
typeof data.cluster_id === "number"
|
||||
? (data.cluster_id as number)
|
||||
: null,
|
||||
case_name:
|
||||
typeof data.case_name === "string"
|
||||
? (data.case_name as string)
|
||||
: null,
|
||||
citation:
|
||||
typeof data.citation === "string"
|
||||
? (data.citation as string)
|
||||
: null,
|
||||
url: data.url as string,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.type === "case_opinions") {
|
||||
pushEvent({
|
||||
type: "case_opinions",
|
||||
cluster_id:
|
||||
typeof data.cluster_id === "number"
|
||||
? (data.cluster_id as number)
|
||||
: 0,
|
||||
case: data.case as Extract<
|
||||
AssistantEvent,
|
||||
{ type: "case_opinions" }
|
||||
>["case"],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.type === "doc_read_start") {
|
||||
pushEvent({
|
||||
type: "doc_read",
|
||||
|
|
@ -1337,7 +1756,10 @@ export function TRChatPanel({
|
|||
return (
|
||||
<div
|
||||
style={{ width: panelWidth }}
|
||||
className="shrink-0 flex flex-col border-r border-gray-200 bg-white h-full relative"
|
||||
className={cn(
|
||||
"shrink-0 flex flex-col border-r border-gray-200 h-full relative",
|
||||
"bg-transparent",
|
||||
)}
|
||||
>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
|
|
@ -1352,9 +1774,15 @@ export function TRChatPanel({
|
|||
}`}
|
||||
/>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between h-8 px-2 border-b border-gray-200 shrink-0">
|
||||
<div className="flex items-center gap-1.5 px-2 min-w-0">
|
||||
<MikeIcon mike size={14} />
|
||||
<div className="flex items-center justify-between h-8 pr-2 border-b border-gray-200 shrink-0">
|
||||
<div className="flex items-center gap-1 pl-2 pr-2 min-w-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
title="Close"
|
||||
className="flex items-center justify-center h-7 w-7 shrink-0 rounded-md text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div
|
||||
onMouseEnter={(e) => {
|
||||
const el = e.currentTarget;
|
||||
|
|
@ -1374,7 +1802,7 @@ export function TRChatPanel({
|
|||
className="min-w-0 overflow-x-hidden whitespace-nowrap scrollbar-none"
|
||||
>
|
||||
<span className="text-xs font-medium text-gray-700">
|
||||
{currentChatTitle ?? "Assistant"}
|
||||
{currentChatTitle ?? "New chat"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1383,7 +1811,7 @@ export function TRChatPanel({
|
|||
<button
|
||||
onClick={() => setHistoryOpen((v) => !v)}
|
||||
title="Chat history"
|
||||
className={`flex items-center justify-center h-7 w-7 rounded-md transition-colors ${historyOpen ? "text-gray-900" : "text-gray-400 hover:text-gray-700"}`}
|
||||
className={`flex items-center justify-center h-7 w-7 rounded-md transition-colors ${historyOpen ? "text-gray-900" : "text-gray-600 hover:text-gray-900"}`}
|
||||
>
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
|
@ -1400,7 +1828,7 @@ export function TRChatPanel({
|
|||
<button
|
||||
onClick={handleNewChat}
|
||||
title="New chat"
|
||||
className="flex items-center justify-center h-7 w-7 rounded-md text-gray-400 hover:text-gray-700 transition-colors"
|
||||
className="flex items-center justify-center h-7 w-7 rounded-md text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<MessageSquarePlus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
|
@ -1408,18 +1836,11 @@ export function TRChatPanel({
|
|||
<button
|
||||
onClick={handleDeleteChat}
|
||||
title="Delete chat"
|
||||
className="flex items-center justify-center h-7 w-7 rounded-md text-gray-400 hover:text-red-600 transition-colors"
|
||||
className="flex items-center justify-center h-7 w-7 rounded-md text-gray-600 hover:text-red-600 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
title="Close"
|
||||
className="flex items-center justify-center h-7 w-7 rounded-md text-gray-400 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1432,7 +1853,7 @@ export function TRChatPanel({
|
|||
{messages.length === 0 && !isLoadingMessages && (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2">
|
||||
<MikeIcon size={24} />
|
||||
<p className="text-sm text-gray-400 text-center">
|
||||
<p className="text-gray-400 font-serif text-center">
|
||||
Ask a question about this tabular review.
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -85,8 +85,6 @@ export function TREditColumnMenu({
|
|||
setSaving(false);
|
||||
}
|
||||
}
|
||||
console.log(tags);
|
||||
|
||||
async function handleDelete() {
|
||||
setDeleting(true);
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -12,11 +12,16 @@ import {
|
|||
RefreshCw,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { ColumnConfig, MikeDocument, TabularCell } from "../shared/types";
|
||||
import type {
|
||||
ColumnConfig,
|
||||
Document,
|
||||
TabularCell,
|
||||
} 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 { cn } from "@/lib/utils";
|
||||
|
||||
function isDocxDocument(d: {
|
||||
file_type?: string | null;
|
||||
|
|
@ -30,7 +35,7 @@ function isDocxDocument(d: {
|
|||
|
||||
interface Props {
|
||||
cell: TabularCell;
|
||||
document: MikeDocument;
|
||||
document: Document;
|
||||
column: ColumnConfig;
|
||||
columns: ColumnConfig[];
|
||||
onClose: () => void;
|
||||
|
|
@ -109,22 +114,16 @@ export function TRSidePanel({
|
|||
const { processed: reasoningText, citations: reasoningCitations } =
|
||||
preprocessCitations(cell.content?.reasoning ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
console.log("[TRSidePanel] summary:", cell.content?.summary ?? "");
|
||||
}, [cell.id, cell.content?.summary]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed right-0 top-0 bottom-0 z-100 flex flex-row shadow-md border-l border-gray-200"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.08)",
|
||||
backdropFilter: "blur(10px) saturate(50%)",
|
||||
WebkitBackdropFilter: "blur(10px) saturate(50%)",
|
||||
}}
|
||||
className={cn(
|
||||
"fixed z-100 flex flex-row",
|
||||
"right-3 top-3 bottom-3 overflow-hidden rounded-2xl border border-white/70 bg-white/20 shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18),inset_1px_0_0_rgba(255,255,255,0.5)] backdrop-blur-2xl",
|
||||
)}
|
||||
>
|
||||
{/* Document panel — left, 600px */}
|
||||
{docCitation !== undefined && (
|
||||
<div className="relative flex w-[600px] shrink-0 flex-col border-r border-white/30 px-3">
|
||||
<div className="relative flex w-[600px] shrink-0 flex-col border-r border-white/30 px-3 pb-3">
|
||||
{/* Doc header */}
|
||||
<div className="flex items-center gap-2 pt-3 shrink-0 border-b border-white/30">
|
||||
<p
|
||||
|
|
@ -255,7 +254,9 @@ export function TRSidePanel({
|
|||
</span>
|
||||
</div>
|
||||
{/* Document name */}
|
||||
<p className="text-xs mb-4">{doc.filename}</p>
|
||||
<p className="text-xs mb-4">
|
||||
{doc.filename}
|
||||
</p>
|
||||
|
||||
{/* Flag section */}
|
||||
{cell.content?.flag && (
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@
|
|||
|
||||
import { forwardRef, useImperativeHandle, useRef } from "react";
|
||||
import { Loader2, Plus, Table2, Upload } from "lucide-react";
|
||||
import type { ColumnConfig, MikeDocument, TabularCell } from "../shared/types";
|
||||
import type {
|
||||
ColumnConfig,
|
||||
Document,
|
||||
TabularCell,
|
||||
} from "../shared/types";
|
||||
import { TabularCell as TabularCellComponent } from "./TabularCell";
|
||||
import { TREditColumnMenu } from "./TREditColumnMenu";
|
||||
|
||||
|
|
@ -10,13 +14,12 @@ const SKELETON_COLS = 4;
|
|||
const SKELETON_ROWS = 5;
|
||||
|
||||
const COL_W = "w-[300px] shrink-0";
|
||||
const CHECK_W = "w-8 shrink-0";
|
||||
const DOC_COL_W = "w-[332px] shrink-0";
|
||||
|
||||
// Pixel widths matching the CSS constants above
|
||||
const CHECK_W_PX = 32; // w-8 = 2rem = 32px
|
||||
const DOC_COL_W_PX = 300;
|
||||
const DOC_COL_W_PX = 332;
|
||||
const DATA_COL_W_PX = 300;
|
||||
const STICKY_LEFT_PX = CHECK_W_PX + DOC_COL_W_PX; // 332px
|
||||
const STICKY_LEFT_PX = DOC_COL_W_PX;
|
||||
|
||||
export interface TRTableHandle {
|
||||
scrollToCell: (colIdx: number, rowIdx: number) => void;
|
||||
|
|
@ -25,7 +28,7 @@ export interface TRTableHandle {
|
|||
interface Props {
|
||||
loading: boolean;
|
||||
columns: ColumnConfig[];
|
||||
documents: MikeDocument[];
|
||||
documents: Document[];
|
||||
cells: TabularCell[];
|
||||
savingColumn: boolean;
|
||||
savingColumnsConfig: boolean;
|
||||
|
|
@ -64,10 +67,11 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
},
|
||||
ref,
|
||||
) {
|
||||
const stickyCellBg = "bg-[#fcfcfd]";
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const sortedColumns = [...columns].sort((a, b) => a.index - b.index);
|
||||
const totalContentWidth =
|
||||
CHECK_W_PX + DOC_COL_W_PX + sortedColumns.length * DATA_COL_W_PX + 32;
|
||||
DOC_COL_W_PX + sortedColumns.length * DATA_COL_W_PX + 32;
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToCell(colIdx: number, rowIdx: number) {
|
||||
|
|
@ -130,12 +134,10 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
{/* Header */}
|
||||
<div className="flex border-b border-gray-200">
|
||||
<div
|
||||
className={`${CHECK_W} border-r border-gray-200 p-2`}
|
||||
/>
|
||||
<div
|
||||
className={`${COL_W} border-r border-gray-200 p-2 text-xs font-medium text-gray-500`}
|
||||
className={`${DOC_COL_W} flex items-center gap-4 border-r border-gray-200 py-2 pl-4 pr-2 text-xs font-medium text-gray-500`}
|
||||
>
|
||||
Document
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
|
||||
<span>Document</span>
|
||||
</div>
|
||||
{Array.from({ length: SKELETON_COLS }).map((_, i) => (
|
||||
<div
|
||||
|
|
@ -151,10 +153,10 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
{Array.from({ length: SKELETON_ROWS }).map((_, row) => (
|
||||
<div
|
||||
key={row}
|
||||
className={`flex border-b border-gray-50 ${row % 2 === 0 ? "bg-white" : "bg-gray-50/50"}`}
|
||||
className={`flex border-b border-gray-50 ${row % 2 === 0 ? "" : "bg-gray-50/50"}`}
|
||||
>
|
||||
<div className={`${CHECK_W} p-2`} />
|
||||
<div className={`${COL_W} p-2`}>
|
||||
<div className={`${DOC_COL_W} flex items-center gap-4 py-2 pl-4 pr-2`}>
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-4 w-32 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
{Array.from({ length: SKELETON_COLS }).map((_, col) => (
|
||||
|
|
@ -177,9 +179,8 @@ 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={`${CHECK_W} border-r border-gray-200`} />
|
||||
<div
|
||||
className={`${COL_W} border-r border-gray-200 p-2 text-xs font-medium text-gray-500 select-none`}
|
||||
className={`${DOC_COL_W} border-r border-gray-200 py-2 pl-4 pr-2 text-xs font-medium text-gray-500 select-none`}
|
||||
>
|
||||
Document
|
||||
</div>
|
||||
|
|
@ -225,11 +226,11 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="sticky top-0 z-20 flex bg-white h-8"
|
||||
className={`sticky top-0 z-20 flex h-8 ${stickyCellBg}`}
|
||||
style={{ minWidth: totalContentWidth }}
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-30 ${CHECK_W} bg-white border-b border-r border-gray-200 flex justify-center items-center select-none`}
|
||||
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`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -240,11 +241,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
onChange={toggleAll}
|
||||
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`sticky left-8 z-30 ${COL_W} bg-white border-b border-r border-gray-200 p-2 text-left text-xs font-medium text-gray-500 select-none`}
|
||||
>
|
||||
Document
|
||||
<span>Document</span>
|
||||
</div>
|
||||
{columns.map((col) => (
|
||||
<div
|
||||
|
|
@ -281,21 +278,17 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
{uploadingFilenames.map((filename) => (
|
||||
<div
|
||||
key={`uploading-${filename}`}
|
||||
className="flex bg-white"
|
||||
className="flex"
|
||||
style={{ minWidth: totalContentWidth }}
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} border-b border-r border-gray-200 p-2 flex items-center justify-center bg-white`}
|
||||
className={`sticky left-0 z-[60] ${DOC_COL_W} ${stickyCellBg} border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs text-gray-400 flex items-center gap-4`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled
|
||||
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-default accent-black disabled:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`sticky left-8 z-[60] ${COL_W} border-b border-r border-gray-200 p-2 text-xs text-gray-400 flex items-center gap-2 bg-white`}
|
||||
>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
|
||||
<span className="line-clamp-1" title={filename}>
|
||||
{filename}
|
||||
|
|
@ -314,7 +307,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
))}
|
||||
{documents.map((doc, docIdx) => {
|
||||
const baseRowBg =
|
||||
docIdx % 2 === 0 ? "bg-white" : "bg-gray-50";
|
||||
docIdx % 2 === 0 ? stickyCellBg : "bg-gray-50";
|
||||
const rowBg = selectedDocIds.includes(doc.id)
|
||||
? "bg-gray-100"
|
||||
: baseRowBg;
|
||||
|
|
@ -325,7 +318,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
style={{ minWidth: totalContentWidth }}
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} border-b border-r border-gray-200 p-2 flex items-center justify-center ${rowBg}`}
|
||||
className={`sticky left-0 z-[60] ${DOC_COL_W} border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs text-gray-800 flex items-center gap-4 ${rowBg}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -333,10 +326,6 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
|
|||
onChange={() => toggleDoc(doc.id)}
|
||||
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`sticky left-8 z-[60] ${COL_W} border-b border-r border-gray-200 p-2 text-xs text-gray-800 flex items-center ${baseRowBg}`}
|
||||
>
|
||||
<span
|
||||
className="line-clamp-1"
|
||||
title={doc.filename}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Plus, Loader2, Play, ChevronDown, MessageSquare, Download, Users, Upload } from "lucide-react";
|
||||
import { HeaderSearchBtn } from "../shared/HeaderSearchBtn";
|
||||
import { Plus, Loader2, Play, ChevronDown, MessageSquare, Download, Users, Upload, X } from "lucide-react";
|
||||
|
||||
import {
|
||||
clearTabularCells,
|
||||
|
|
@ -17,8 +16,8 @@ import {
|
|||
} from "@/app/lib/mikeApi";
|
||||
import type {
|
||||
ColumnConfig,
|
||||
MikeDocument,
|
||||
MikeProject,
|
||||
Document,
|
||||
Project,
|
||||
TabularCell,
|
||||
TabularReview,
|
||||
} from "../shared/types";
|
||||
|
|
@ -42,6 +41,7 @@ import type { TRTableHandle } from "./TRTable";
|
|||
import { TRChatPanel } from "./TRChatPanel";
|
||||
import { exportTabularReviewToExcel } from "./exportToExcel";
|
||||
import { useSidebar } from "@/app/contexts/SidebarContext";
|
||||
import { PageHeader } from "../shared/PageHeader";
|
||||
|
||||
interface Props {
|
||||
reviewId: string;
|
||||
|
|
@ -51,9 +51,9 @@ interface Props {
|
|||
export function TRView({ reviewId, projectId }: Props) {
|
||||
const { setSidebarOpen } = useSidebar();
|
||||
const [review, setReview] = useState<TabularReview | null>(null);
|
||||
const [project, setProject] = useState<MikeProject | null>(null);
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [cells, setCells] = useState<TabularCell[]>([]);
|
||||
const [documents, setDocuments] = useState<MikeDocument[]>([]);
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [columns, setColumns] = useState<ColumnConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
|
@ -160,7 +160,7 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleAddDocuments(newDocs: MikeDocument[]) {
|
||||
async function handleAddDocuments(newDocs: Document[]) {
|
||||
const toAdd = newDocs.filter(
|
||||
(d) => !documents.some((existing) => existing.id === d.id),
|
||||
);
|
||||
|
|
@ -201,7 +201,7 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
if (files.length === 0) return;
|
||||
setUploadingDroppedFilenames(files.map((file) => file.name));
|
||||
try {
|
||||
const uploaded: MikeDocument[] = [];
|
||||
const uploaded: Document[] = [];
|
||||
const documentIds = documents.map((document) => document.id);
|
||||
for (const file of files) {
|
||||
const document = await uploadReviewDocument(reviewId, file, {
|
||||
|
|
@ -526,135 +526,123 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
: documents;
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden bg-white">
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="mb-1 bg-white px-4 py-3 md:px-10 flex items-start justify-between shrink-0 gap-4">
|
||||
<div className="flex items-center gap-1.5 text-2xl font-medium font-serif">
|
||||
{projectId && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => router.push("/projects")}
|
||||
className="text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Projects
|
||||
</button>
|
||||
<span className="text-gray-300">›</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
router.push(`/projects/${projectId}`)
|
||||
}
|
||||
className="text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="h-6 w-32 rounded bg-gray-100 animate-pulse" />
|
||||
) : (
|
||||
<>
|
||||
{project?.name ?? ""}
|
||||
{project?.cm_number && (
|
||||
<PageHeader
|
||||
align="start"
|
||||
shrink
|
||||
className="gap-4"
|
||||
breadcrumbs={[
|
||||
...(projectId
|
||||
? [
|
||||
{
|
||||
label: "Projects",
|
||||
onClick: () => router.push("/projects"),
|
||||
},
|
||||
loading
|
||||
? {
|
||||
loading: true,
|
||||
skeletonClassName: "w-32",
|
||||
onClick: () =>
|
||||
router.push(`/projects/${projectId}`),
|
||||
title: "Back to project",
|
||||
}
|
||||
: {
|
||||
label: project?.name ?? "",
|
||||
suffix: project?.cm_number ? (
|
||||
<span className="ml-1 text-gray-400">
|
||||
(#{project.cm_number})
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<span className="text-gray-300">›</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/projects/${projectId}?tab=reviews`,
|
||||
)
|
||||
}
|
||||
className="text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Tabular Reviews
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!projectId && (
|
||||
<button
|
||||
onClick={() => router.push("/tabular-reviews")}
|
||||
className="text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Tabular Reviews
|
||||
</button>
|
||||
)}
|
||||
<span className="text-gray-300">›</span>
|
||||
{loading ? (
|
||||
<div className="h-6 w-40 rounded bg-gray-100 animate-pulse" />
|
||||
) : (
|
||||
<RenameableTitle
|
||||
value={review?.title || "Untitled Review"}
|
||||
onCommit={handleTitleCommit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!loading && (
|
||||
<div className="flex items-center gap-2">
|
||||
<HeaderSearchBtn value={search} onChange={setSearch} placeholder="Search documents…" />
|
||||
{!projectId && (
|
||||
<button
|
||||
onClick={() => setPeopleModalOpen(true)}
|
||||
disabled={loading}
|
||||
className={`flex h-8 w-8 items-center justify-center text-sm transition-colors ${
|
||||
loading
|
||||
? "text-gray-300 cursor-default"
|
||||
: "text-gray-500 hover:text-gray-900 cursor-pointer"
|
||||
}`}
|
||||
title="People with access"
|
||||
aria-label="People with access"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() =>
|
||||
exportTabularReviewToExcel({
|
||||
reviewTitle: review?.title || "Tabular Review",
|
||||
columns,
|
||||
documents,
|
||||
cells,
|
||||
})
|
||||
}
|
||||
disabled={columns.length === 0 || documents.length === 0}
|
||||
title="Export to Excel"
|
||||
className={`flex h-8 items-center justify-center gap-1.5 px-3 text-sm transition-colors ${
|
||||
columns.length === 0 || documents.length === 0
|
||||
? "text-gray-300 cursor-default"
|
||||
: "text-gray-700 hover:text-gray-900 cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={
|
||||
generating ||
|
||||
columns.length === 0 ||
|
||||
documents.length === 0 ||
|
||||
savingColumnsConfig
|
||||
}
|
||||
className={`flex h-8 items-center justify-center gap-1.5 px-3 text-sm transition-colors ${
|
||||
generating ||
|
||||
columns.length === 0 ||
|
||||
documents.length === 0 ||
|
||||
savingColumnsConfig
|
||||
? "text-gray-300 cursor-default"
|
||||
: "text-gray-700 hover:text-gray-900 cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
{generating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
{generating ? "Running…" : "Run"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null,
|
||||
onClick: () =>
|
||||
router.push(`/projects/${projectId}`),
|
||||
title: "Back to project",
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: "Tabular Reviews",
|
||||
onClick: () => router.push("/tabular-reviews"),
|
||||
title: "Back to Tabular Reviews",
|
||||
},
|
||||
]),
|
||||
loading
|
||||
? {
|
||||
loading: true,
|
||||
skeletonClassName: "w-40",
|
||||
}
|
||||
: {
|
||||
label: (
|
||||
<RenameableTitle
|
||||
value={review?.title || "Untitled Review"}
|
||||
onCommit={handleTitleCommit}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
!loading
|
||||
? [
|
||||
{
|
||||
type: "search",
|
||||
value: search,
|
||||
onChange: setSearch,
|
||||
placeholder: "Search documents…",
|
||||
},
|
||||
!projectId
|
||||
? {
|
||||
onClick: () =>
|
||||
setPeopleModalOpen(true),
|
||||
disabled: loading,
|
||||
iconOnly: true,
|
||||
title: "People with access",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
}
|
||||
: null,
|
||||
{
|
||||
onClick: () =>
|
||||
exportTabularReviewToExcel({
|
||||
reviewTitle:
|
||||
review?.title ||
|
||||
"Tabular Review",
|
||||
columns,
|
||||
documents,
|
||||
cells,
|
||||
}),
|
||||
disabled:
|
||||
columns.length === 0 ||
|
||||
documents.length === 0,
|
||||
title: "Export to Excel",
|
||||
icon: <Download className="h-4 w-4" />,
|
||||
label: (
|
||||
<span className="hidden sm:inline">
|
||||
Export
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
onClick: handleGenerate,
|
||||
disabled:
|
||||
generating ||
|
||||
columns.length === 0 ||
|
||||
documents.length === 0 ||
|
||||
savingColumnsConfig,
|
||||
icon: generating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
),
|
||||
label: (
|
||||
<span className="hidden sm:inline">
|
||||
{generating ? "Running…" : "Run"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center h-10 px-4 md:px-10 border-b border-gray-200 gap-4">
|
||||
|
|
@ -671,8 +659,12 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
: "text-gray-700 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
Assistant in Tabular Review
|
||||
{chatOpen ? (
|
||||
<X className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Assistant
|
||||
</button>
|
||||
<div className="ml-auto flex items-center gap-5">
|
||||
{loading ? (
|
||||
|
|
@ -870,7 +862,7 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
<AddProjectDocsModal
|
||||
open={addDocsOpen}
|
||||
onClose={() => setAddDocsOpen(false)}
|
||||
onSelect={(docs: MikeDocument[]) =>
|
||||
onSelect={(docs: Document[]) =>
|
||||
handleAddDocuments(docs)
|
||||
}
|
||||
breadcrumb={[
|
||||
|
|
@ -890,7 +882,7 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
<AddDocumentsModal
|
||||
open={addDocsOpen}
|
||||
onClose={() => setAddDocsOpen(false)}
|
||||
onSelect={(docs: MikeDocument[]) =>
|
||||
onSelect={(docs: Document[]) =>
|
||||
handleAddDocuments(docs)
|
||||
}
|
||||
breadcrumb={[
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import ExcelJS from "exceljs";
|
||||
import type { ColumnConfig, MikeDocument, TabularCell } from "../shared/types";
|
||||
import type {
|
||||
ColumnConfig,
|
||||
Document,
|
||||
TabularCell,
|
||||
} from "../shared/types";
|
||||
import { preprocessCitations } from "./citation-utils";
|
||||
|
||||
function formatCellForExport(cell: TabularCell | undefined): string {
|
||||
|
|
@ -31,7 +35,7 @@ function sanitizeFilename(name: string): string {
|
|||
export async function exportTabularReviewToExcel(params: {
|
||||
reviewTitle: string;
|
||||
columns: ColumnConfig[];
|
||||
documents: MikeDocument[];
|
||||
documents: Document[];
|
||||
cells: TabularCell[];
|
||||
}) {
|
||||
const { reviewTitle, columns, documents, cells } = params;
|
||||
|
|
|
|||
|
|
@ -12,18 +12,21 @@ import {
|
|||
} from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { MikeDocument, MikeWorkflow } from "../shared/types";
|
||||
import type {
|
||||
Document,
|
||||
Workflow,
|
||||
} from "../shared/types";
|
||||
import { createTabularReview } from "@/app/lib/mikeApi";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { formatIcon, formatLabel } from "../tabular/columnFormat";
|
||||
import { useDirectoryData } from "../shared/useDirectoryData";
|
||||
import { FileDirectory } from "../shared/FileDirectory";
|
||||
import type { MikeProject } from "../shared/types";
|
||||
import type { Project } from "../shared/types";
|
||||
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
|
||||
|
||||
interface Props {
|
||||
workflows: MikeWorkflow[];
|
||||
workflow: MikeWorkflow | null;
|
||||
workflows: Workflow[];
|
||||
workflow: Workflow | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -52,7 +55,7 @@ function SimpleProjectPicker({
|
|||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
projects: MikeProject[];
|
||||
projects: Project[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
}) {
|
||||
|
|
@ -172,7 +175,7 @@ function MarkdownBody({ content }: { content: string }) {
|
|||
// ---------------------------------------------------------------------------
|
||||
// Right panel for assistant workflows (select screen)
|
||||
// ---------------------------------------------------------------------------
|
||||
function AssistantPanel({ workflow }: { workflow: MikeWorkflow }) {
|
||||
function AssistantPanel({ workflow }: { workflow: Workflow }) {
|
||||
return (
|
||||
<div className="flex-1 border-l border-t border-gray-200 flex flex-col overflow-hidden px-3 pb-3">
|
||||
<div className="py-3 shrink-0">
|
||||
|
|
@ -192,7 +195,7 @@ function AssistantPanel({ workflow }: { workflow: MikeWorkflow }) {
|
|||
// ---------------------------------------------------------------------------
|
||||
// Right panel for tabular workflows — accordion column list (select screen)
|
||||
// ---------------------------------------------------------------------------
|
||||
function TabularPanel({ workflow }: { workflow: MikeWorkflow }) {
|
||||
function TabularPanel({ workflow }: { workflow: Workflow }) {
|
||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||
const columns = (workflow.columns_config ?? []).sort(
|
||||
(a, b) => a.index - b.index,
|
||||
|
|
@ -283,7 +286,7 @@ function TabularPanel({ workflow }: { workflow: MikeWorkflow }) {
|
|||
// ---------------------------------------------------------------------------
|
||||
export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
||||
const [screen, setScreen] = useState<"select" | "configure">("select");
|
||||
const [selected, setSelected] = useState<MikeWorkflow | null>(workflow);
|
||||
const [selected, setSelected] = useState<Workflow | null>(workflow);
|
||||
const [listSearch, setListSearch] = useState("");
|
||||
const selectedRowRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
|
|
@ -352,13 +355,16 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
|||
const projectId = inProject ? selectedProjectId! : undefined;
|
||||
const chatId = await saveChat(projectId);
|
||||
if (!chatId) return;
|
||||
const allDocs: MikeDocument[] = [
|
||||
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 }));
|
||||
.map((d) => ({
|
||||
filename: d.filename,
|
||||
document_id: d.id,
|
||||
}));
|
||||
const content = assistantPrompt.trim()
|
||||
? `implement workflow\n\n${assistantPrompt.trim()}`
|
||||
: "implement workflow";
|
||||
|
|
@ -381,7 +387,7 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
|||
}
|
||||
|
||||
async function handleCreateReview() {
|
||||
const allDocs: MikeDocument[] = [
|
||||
const allDocs: Document[] = [
|
||||
...standaloneDocuments,
|
||||
...projects.flatMap((p) => p.documents || []),
|
||||
];
|
||||
|
|
@ -418,7 +424,9 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
|||
const projectDocs = selectedProject?.documents ?? [];
|
||||
|
||||
const filteredProjectDocs = q
|
||||
? projectDocs.filter((d) => d.filename.toLowerCase().includes(q))
|
||||
? projectDocs.filter((d) =>
|
||||
d.filename.toLowerCase().includes(q),
|
||||
)
|
||||
: projectDocs;
|
||||
|
||||
const filteredStandalone = q
|
||||
|
|
@ -431,7 +439,8 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
|
|||
.map((p) => ({
|
||||
...p,
|
||||
documents: (p.documents || []).filter(
|
||||
(d) => !q || d.filename.toLowerCase().includes(q),
|
||||
(d) =>
|
||||
!q || d.filename.toLowerCase().includes(q),
|
||||
),
|
||||
}))
|
||||
.filter(
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { X, MessageSquare, Table2 } from "lucide-react";
|
||||
import { MessageSquare, Table2 } from "lucide-react";
|
||||
import { createWorkflow, updateWorkflow } from "@/app/lib/mikeApi";
|
||||
import type { MikeWorkflow } from "../shared/types";
|
||||
import type { Workflow } from "../shared/types";
|
||||
import { PRACTICE_OPTIONS } from "./practices";
|
||||
import { Modal } from "../shared/Modal";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: (workflow: MikeWorkflow) => void;
|
||||
editWorkflow?: MikeWorkflow;
|
||||
onUpdated?: (workflow: MikeWorkflow) => void;
|
||||
onCreated: (workflow: Workflow) => void;
|
||||
editWorkflow?: Workflow;
|
||||
onUpdated?: (workflow: Workflow) => void;
|
||||
}
|
||||
|
||||
export function NewWorkflowModal({ open, onClose, onCreated, editWorkflow, onUpdated }: Props) {
|
||||
|
|
@ -26,6 +27,7 @@ export function NewWorkflowModal({ open, onClose, onCreated, editWorkflow, onUpd
|
|||
const isEditing = !!editWorkflow;
|
||||
const isOthers = practice === "Others";
|
||||
const effectivePractice = isOthers ? (customPractice.trim() || null) : (practice || null);
|
||||
const formId = "workflow-modal-form";
|
||||
|
||||
useEffect(() => {
|
||||
if (open && editWorkflow) {
|
||||
|
|
@ -95,124 +97,106 @@ export function NewWorkflowModal({ open, onClose, onCreated, editWorkflow, onUpd
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-101 flex items-center justify-center bg-black/20 backdrop-blur-xs">
|
||||
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl overflow-hidden flex flex-col" style={{ height: 600 }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 pt-5 pb-2 shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
<span>Workflows</span>
|
||||
<span>›</span>
|
||||
<span>{isEditing ? "Edit workflow" : "New workflow"}</span>
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
breadcrumbs={[
|
||||
"Workflows",
|
||||
isEditing ? "Edit workflow" : "New workflow",
|
||||
]}
|
||||
primaryAction={{
|
||||
label: loading
|
||||
? isEditing
|
||||
? "Saving…"
|
||||
: "Creating…"
|
||||
: isEditing
|
||||
? "Save changes"
|
||||
: "Create workflow",
|
||||
type: "submit",
|
||||
form: formId,
|
||||
disabled: !title.trim() || loading,
|
||||
}}
|
||||
>
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col flex-1 min-h-0"
|
||||
>
|
||||
<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
|
||||
/>
|
||||
|
||||
{!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>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div 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>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
|
||||
{/* Body */}
|
||||
<div className="px-6 pt-3 pb-5 flex-1 overflow-y-auto">
|
||||
{/* Title */}
|
||||
<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
|
||||
/>
|
||||
|
||||
{/* Type pills — only shown when creating */}
|
||||
{!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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Practice */}
|
||||
<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>
|
||||
|
||||
{error && (
|
||||
<p className="mt-4 text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 border-t border-gray-100 px-6 py-4 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="rounded-lg px-4 py-2 text-sm text-gray-500 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!title.trim() || loading}
|
||||
className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{loading ? (isEditing ? "Saving…" : "Creating…") : (isEditing ? "Save changes" : "Create workflow")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-4 text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import {
|
||||
deleteWorkflowShare,
|
||||
|
|
@ -10,6 +9,7 @@ import {
|
|||
} from "@/app/lib/mikeApi";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { EmailPillInput } from "../shared/EmailPillInput";
|
||||
import { Modal } from "../shared/Modal";
|
||||
|
||||
interface Share {
|
||||
id: string;
|
||||
|
|
@ -67,103 +67,74 @@ export function ShareWorkflowModal({
|
|||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[101] flex items-center justify-center bg-black/20 backdrop-blur-xs">
|
||||
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
<span>Workflows</span>
|
||||
<span>›</span>
|
||||
<span className="truncate max-w-[220px]">
|
||||
{workflowName}
|
||||
</span>
|
||||
<span>›</span>
|
||||
<span>People</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600">
|
||||
<X className="h-4 w-4" />
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
breadcrumbs={["Workflows", workflowName, "People"]}
|
||||
primaryAction={{
|
||||
label: saving ? "Sharing…" : "Share",
|
||||
onClick: handleConfirm,
|
||||
disabled: saving || pendingEmails.length === 0,
|
||||
}}
|
||||
>
|
||||
<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
|
||||
/>
|
||||
|
||||
{/* Permission toggle */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 flex flex-col gap-4 flex-1 overflow-y-auto">
|
||||
<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
|
||||
/>
|
||||
|
||||
{/* Permission toggle */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Existing access */}
|
||||
<div>
|
||||
<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>
|
||||
{/* Existing access */}
|
||||
<div>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-gray-100 px-5 py-3 flex justify-end gap-2 mt-auto shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-5 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={saving || pendingEmails.length === 0}
|
||||
className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{saving ? "Sharing…" : "Share"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-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";
|
||||
|
||||
interface Props {
|
||||
col: ColumnConfig;
|
||||
|
|
@ -14,55 +13,46 @@ interface Props {
|
|||
|
||||
export function WFColumnViewModal({ col, onClose }: Props) {
|
||||
const FormatIcon = formatIcon(col.format ?? "text");
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[101] flex items-center justify-center bg-black/20 backdrop-blur-xs">
|
||||
<div className="w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col h-[600px]">
|
||||
<div className="flex items-center justify-between px-6 pt-5 pb-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-400">
|
||||
<span>Workflows</span>
|
||||
<span>›</span>
|
||||
<span className="truncate max-w-[200px] text-gray-600">{col.name}</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
breadcrumbs={["Workflows", col.name]}
|
||||
primaryAction={{
|
||||
label: "Close",
|
||||
onClick: onClose,
|
||||
}}
|
||||
cancelAction={false}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 mb-2">Column Title</p>
|
||||
<p className="text-sm text-gray-800">{col.name}</p>
|
||||
</div>
|
||||
<div className="px-6 pt-3 pb-5 flex flex-col gap-4 overflow-y-auto flex-1">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 mb-2">Format</p>
|
||||
<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" />
|
||||
{formatLabel(col.format ?? "text")}
|
||||
</span>
|
||||
</div>
|
||||
{col.tags && col.tags.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 mb-2">Column Title</p>
|
||||
<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>
|
||||
<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" />
|
||||
{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>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 mb-2">Prompt</p>
|
||||
<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>
|
||||
<p className="text-sm font-medium text-gray-500 mb-2.5">Tags</p>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-100 px-6 py-4 flex justify-end shrink-0">
|
||||
<button onClick={onClose} className="rounded-lg bg-gray-900 px-5 py-2 text-sm font-medium text-white hover:bg-gray-700">
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 mb-2">Prompt</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Plus,
|
||||
Library,
|
||||
Table2,
|
||||
MessageSquare,
|
||||
|
|
@ -11,7 +10,6 @@ import {
|
|||
ChevronDown,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { HeaderSearchBtn } from "../shared/HeaderSearchBtn";
|
||||
import {
|
||||
listWorkflows,
|
||||
deleteWorkflow,
|
||||
|
|
@ -19,7 +17,7 @@ import {
|
|||
hideWorkflow,
|
||||
unhideWorkflow,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import type { MikeWorkflow } from "../shared/types";
|
||||
import type { Workflow } from "../shared/types";
|
||||
import { BUILT_IN_WORKFLOWS, BUILT_IN_IDS } from "./builtinWorkflows";
|
||||
import { DisplayWorkflowModal } from "./DisplayWorkflowModal";
|
||||
import { NewWorkflowModal } from "./NewWorkflowModal";
|
||||
|
|
@ -27,11 +25,11 @@ import { ToolbarTabs } from "../shared/ToolbarTabs";
|
|||
import { RowActions } from "../shared/RowActions";
|
||||
import { MikeIcon } from "@/components/chat/mike-icon";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageHeader } from "@/app/components/shared/PageHeader";
|
||||
|
||||
type Tab = "all" | "builtin" | "custom" | "hidden";
|
||||
|
||||
const CHECK_W = "w-8 shrink-0";
|
||||
const NAME_COL_W = "w-[300px] shrink-0";
|
||||
const NAME_COL_W = "w-[332px] shrink-0";
|
||||
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: "all", label: "All" },
|
||||
|
|
@ -43,9 +41,10 @@ const TABS: { id: Tab; label: string }[] = [
|
|||
export function WorkflowList() {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const [custom, setCustom] = useState<MikeWorkflow[]>([]);
|
||||
const stickyCellBg = "bg-[#fcfcfd]";
|
||||
const [custom, setCustom] = useState<Workflow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState<MikeWorkflow | null>(null);
|
||||
const [selected, setSelected] = useState<Workflow | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<Tab>("all");
|
||||
const [newModalOpen, setNewModalOpen] = useState(false);
|
||||
const [hiddenBuiltinIds, setHiddenBuiltinIds] = useState<string[]>([]);
|
||||
|
|
@ -53,7 +52,7 @@ export function WorkflowList() {
|
|||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
const [practiceFilter, setPracticeFilter] = useState<string | null>(null);
|
||||
const [practiceFilterOpen, setPracticeFilterOpen] = useState(false);
|
||||
const [typeFilter, setTypeFilter] = useState<MikeWorkflow["type"] | null>(
|
||||
const [typeFilter, setTypeFilter] = useState<Workflow["type"] | null>(
|
||||
null,
|
||||
);
|
||||
const [typeFilterOpen, setTypeFilterOpen] = useState(false);
|
||||
|
|
@ -199,7 +198,7 @@ export function WorkflowList() {
|
|||
await Promise.all(ids.map((id) => unhideWorkflow(id).catch(() => {})));
|
||||
}
|
||||
|
||||
const getTypeMeta = (type: MikeWorkflow["type"]) =>
|
||||
const getTypeMeta = (type: Workflow["type"]) =>
|
||||
type === "tabular"
|
||||
? { label: "Tabular", Icon: Table2, className: "text-violet-700" }
|
||||
: {
|
||||
|
|
@ -358,26 +357,28 @@ export function WorkflowList() {
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-hidden bg-white">
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
{/* Page header */}
|
||||
<div className="mb-1 flex items-center justify-between px-4 py-3 md:px-10 shrink-0">
|
||||
<PageHeader
|
||||
shrink
|
||||
actions={[
|
||||
{
|
||||
type: "search",
|
||||
value: search,
|
||||
onChange: setSearch,
|
||||
placeholder: "Search workflows…",
|
||||
},
|
||||
{
|
||||
type: "new",
|
||||
onClick: () => setNewModalOpen(true),
|
||||
title: "New workflow",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<h1 className="text-2xl font-medium font-serif text-gray-900">
|
||||
Workflows
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<HeaderSearchBtn
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search workflows…"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setNewModalOpen(true)}
|
||||
className="flex items-center justify-center p-1.5 text-gray-500 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<ToolbarTabs
|
||||
tabs={TABS}
|
||||
|
|
@ -391,8 +392,10 @@ export function WorkflowList() {
|
|||
<div className="min-w-max">
|
||||
{/* Column headers */}
|
||||
<div className="flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200 text-xs text-gray-500 font-medium select-none">
|
||||
<div className={`sticky left-0 z-[60] ${CHECK_W} relative bg-white flex items-center justify-center self-stretch before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-white`}>
|
||||
{!loading && (
|
||||
<div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}>
|
||||
{loading ? (
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
|
||||
) : (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
|
|
@ -403,9 +406,7 @@ export function WorkflowList() {
|
|||
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white pl-2 text-left`}>
|
||||
Name
|
||||
<span>Name</span>
|
||||
</div>
|
||||
<div className="ml-auto w-28 shrink-0">Type</div>
|
||||
<div className="w-40 shrink-0">Practice</div>
|
||||
|
|
@ -420,8 +421,8 @@ export function WorkflowList() {
|
|||
key={i}
|
||||
className="flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50"
|
||||
>
|
||||
<div className="w-8 shrink-0" />
|
||||
<div className="flex-1 min-w-0 pl-3 pr-4">
|
||||
<div className={`${NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}>
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
|
||||
<div className="h-3.5 w-48 rounded bg-gray-100 animate-pulse" />
|
||||
</div>
|
||||
<div className="w-28 shrink-0">
|
||||
|
|
@ -486,28 +487,26 @@ export function WorkflowList() {
|
|||
filtered.map((wf) => {
|
||||
const rowBg = selectedIds.includes(wf.id)
|
||||
? "bg-gray-50"
|
||||
: "bg-white";
|
||||
: stickyCellBg;
|
||||
return (
|
||||
<div
|
||||
key={wf.id}
|
||||
onClick={() => setSelected(wf)}
|
||||
className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-50 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 cursor-pointer transition-colors"
|
||||
>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${rowBg} group-hover:bg-gray-50`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(wf.id)}
|
||||
onChange={() => toggleOne(wf.id)}
|
||||
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
</div>
|
||||
<div className={`sticky left-8 z-[60] ${NAME_COL_W} p-2 ${rowBg} group-hover:bg-gray-50`}>
|
||||
<span className="text-sm text-gray-800 truncate block">
|
||||
{wf.title}
|
||||
</span>
|
||||
<div className={`sticky left-0 z-[60] ${NAME_COL_W} py-2 pl-4 pr-2 ${rowBg} transition-colors group-hover:bg-gray-100`}>
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(wf.id)}
|
||||
onChange={() => toggleOne(wf.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-2.5 w-2.5 shrink-0 rounded border-gray-200 cursor-pointer accent-black"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-gray-800">
|
||||
{wf.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto w-28 shrink-0">
|
||||
{(() => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { MikeWorkflow } from "../shared/types";
|
||||
import type { Workflow } from "../shared/types";
|
||||
|
||||
export const BUILT_IN_WORKFLOWS: MikeWorkflow[] = [
|
||||
export const BUILT_IN_WORKFLOWS: Workflow[] = [
|
||||
{
|
||||
id: "builtin-cp-checklist",
|
||||
user_id: null,
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@ import {
|
|||
listChats,
|
||||
renameChat,
|
||||
} from "@/app/lib/mikeApi";
|
||||
import type { MikeChat, MikeMessage } from "@/app/components/shared/types";
|
||||
import type { Chat, Message } from "@/app/components/shared/types";
|
||||
|
||||
interface ChatHistoryContextType {
|
||||
chats: MikeChat[] | null;
|
||||
chats: Chat[] | null;
|
||||
hasMoreChats: boolean;
|
||||
currentChatId: string | null;
|
||||
setCurrentChatId: (chatId: string | null) => void;
|
||||
|
|
@ -27,8 +27,8 @@ interface ChatHistoryContextType {
|
|||
loadMoreChats: () => void;
|
||||
saveChat: (projectId?: string) => Promise<string | null>;
|
||||
renameChat: (chatId: string, title: string) => Promise<void>;
|
||||
newChatMessages: MikeMessage[] | null;
|
||||
setNewChatMessages: (messages: MikeMessage[] | null) => void;
|
||||
newChatMessages: Message[] | null;
|
||||
setNewChatMessages: (messages: Message[] | null) => void;
|
||||
replaceChatId: (
|
||||
oldChatId: string,
|
||||
newChatId: string,
|
||||
|
|
@ -46,13 +46,13 @@ const CHAT_LIMIT_INCREMENT = 10;
|
|||
|
||||
export function ChatHistoryProvider({ children }: { children: ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
const [chats, setChats] = useState<MikeChat[] | null>(null);
|
||||
const [chats, setChats] = useState<Chat[] | null>(null);
|
||||
const [chatLimit, setChatLimit] = useState(INITIAL_CHAT_LIMIT);
|
||||
const [hasMoreChats, setHasMoreChats] = useState(false);
|
||||
const [currentChatId, setCurrentChatId] = useState<string | null>(null);
|
||||
const [newChatMessages, setNewChatMessages] = useState<
|
||||
MikeMessage[] | null
|
||||
>(null);
|
||||
const [newChatMessages, setNewChatMessages] = useState<Message[] | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const loadChats = useCallback(async () => {
|
||||
if (!user) {
|
||||
|
|
@ -122,7 +122,7 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) {
|
|||
projectId ? { project_id: projectId } : undefined,
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
const newChat: MikeChat = {
|
||||
const newChat: Chat = {
|
||||
id,
|
||||
project_id: projectId ?? null,
|
||||
user_id: user?.id ?? "",
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@
|
|||
:root {
|
||||
--radius: 0.625rem;
|
||||
--color-azure: 0, 136, 255;
|
||||
--background: oklch(1 0 0);
|
||||
--background: oklch(0.985 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
|
|
@ -442,6 +442,14 @@
|
|||
color: inherit !important;
|
||||
}
|
||||
|
||||
.case-opinion-content span.docx-text-highlight,
|
||||
.case-opinion-content .docx-text-highlight {
|
||||
background-color: rgba(96, 165, 250, 0.55) !important;
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
/* docx-preview tracked-change styling */
|
||||
.docx-view-container ins {
|
||||
color: #16a34a;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -47,14 +47,6 @@ export function useFetchDocxBytes(
|
|||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
console.log("[useFetchDocxBytes] init", {
|
||||
documentId,
|
||||
versionId,
|
||||
refetchKey,
|
||||
initialKey,
|
||||
cacheHit: initialKey ? bytesCache.has(initialKey) : null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!documentId) {
|
||||
setBytes(null);
|
||||
|
|
|
|||
27
frontend/src/app/lib/documentUploadValidation.ts
Normal file
27
frontend/src/app/lib/documentUploadValidation.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
export const SUPPORTED_DOCUMENT_ACCEPT = ".pdf,.docx,.doc";
|
||||
export const UNSUPPORTED_DOCUMENT_WARNING_MESSAGE =
|
||||
"Unsupported file type. Only PDF, DOCX, and DOC files can be uploaded.";
|
||||
|
||||
const SUPPORTED_DOCUMENT_EXTENSIONS = new Set(["pdf", "docx", "doc"]);
|
||||
|
||||
export function isSupportedDocumentFile(file: File): boolean {
|
||||
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||
return !!extension && SUPPORTED_DOCUMENT_EXTENSIONS.has(extension);
|
||||
}
|
||||
|
||||
export function partitionSupportedDocumentFiles(files: File[]) {
|
||||
const supported: File[] = [];
|
||||
const unsupported: File[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (isSupportedDocumentFile(file)) supported.push(file);
|
||||
else unsupported.push(file);
|
||||
}
|
||||
|
||||
return { supported, unsupported };
|
||||
}
|
||||
|
||||
export function formatUnsupportedDocumentWarning(files: File[]): string | null {
|
||||
if (files.length === 0) return null;
|
||||
return UNSUPPORTED_DOCUMENT_WARNING_MESSAGE;
|
||||
}
|
||||
|
|
@ -6,14 +6,14 @@
|
|||
import { supabase } from "@/lib/supabase";
|
||||
import type {
|
||||
AssistantEvent,
|
||||
MikeChat,
|
||||
MikeChatDetailOut,
|
||||
MikeCitationAnnotation,
|
||||
MikeDocument,
|
||||
MikeFolder,
|
||||
MikeMessage,
|
||||
MikeProject,
|
||||
MikeWorkflow,
|
||||
Chat,
|
||||
ChatDetailOut,
|
||||
CitationAnnotation,
|
||||
Document,
|
||||
Folder,
|
||||
Message,
|
||||
Project,
|
||||
Workflow,
|
||||
TabularReview,
|
||||
TabularReviewDetailOut,
|
||||
} from "@/app/components/shared/types";
|
||||
|
|
@ -26,11 +26,11 @@ interface ServerMessage {
|
|||
content: string | AssistantEvent[] | null;
|
||||
files?: { filename: string; document_id?: string }[] | null;
|
||||
workflow?: { id: string; title: string } | null;
|
||||
annotations?: MikeCitationAnnotation[] | null;
|
||||
annotations?: CitationAnnotation[] | null;
|
||||
created_at: string;
|
||||
}
|
||||
interface ServerChatDetailOut {
|
||||
chat: MikeChat;
|
||||
chat: Chat;
|
||||
messages: ServerMessage[];
|
||||
}
|
||||
|
||||
|
|
@ -77,16 +77,16 @@ async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
|||
// Projects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function listProjects(): Promise<MikeProject[]> {
|
||||
return apiRequest<MikeProject[]>("/projects");
|
||||
export async function listProjects(): Promise<Project[]> {
|
||||
return apiRequest<Project[]>("/projects");
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
name: string,
|
||||
cm_number?: string,
|
||||
shared_with?: string[],
|
||||
): Promise<MikeProject> {
|
||||
return apiRequest<MikeProject>("/projects", {
|
||||
): Promise<Project> {
|
||||
return apiRequest<Project>("/projects", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, cm_number, shared_with }),
|
||||
|
|
@ -104,6 +104,7 @@ export interface UserProfile {
|
|||
creditsResetDate: string;
|
||||
creditsRemaining: number;
|
||||
tier: string;
|
||||
titleModel: string;
|
||||
tabularModel: string;
|
||||
apiKeyStatus: ApiKeyStatus;
|
||||
}
|
||||
|
|
@ -115,6 +116,7 @@ export async function getUserProfile(): Promise<UserProfile> {
|
|||
export async function updateUserProfile(payload: {
|
||||
displayName?: string | null;
|
||||
organisation?: string | null;
|
||||
titleModel?: string;
|
||||
tabularModel?: string;
|
||||
}): Promise<UserProfile> {
|
||||
return apiRequest<UserProfile>("/user/profile", {
|
||||
|
|
@ -124,7 +126,12 @@ export async function updateUserProfile(payload: {
|
|||
});
|
||||
}
|
||||
|
||||
export type ApiKeyProvider = "claude" | "gemini" | "openai";
|
||||
export type ApiKeyProvider =
|
||||
| "claude"
|
||||
| "gemini"
|
||||
| "openai"
|
||||
| "openrouter"
|
||||
| "courtlistener";
|
||||
export type ApiKeySource = "user" | "env" | null;
|
||||
export type ApiKeyState = Record<
|
||||
ApiKeyProvider,
|
||||
|
|
@ -153,8 +160,8 @@ export async function saveApiKey(
|
|||
});
|
||||
}
|
||||
|
||||
export async function getProject(projectId: string): Promise<MikeProject> {
|
||||
return apiRequest<MikeProject>(`/projects/${projectId}`);
|
||||
export async function getProject(projectId: string): Promise<Project> {
|
||||
return apiRequest<Project>(`/projects/${projectId}`);
|
||||
}
|
||||
|
||||
export async function updateProject(
|
||||
|
|
@ -164,8 +171,8 @@ export async function updateProject(
|
|||
cm_number?: string;
|
||||
shared_with?: string[];
|
||||
},
|
||||
): Promise<MikeProject> {
|
||||
return apiRequest<MikeProject>(`/projects/${projectId}`, {
|
||||
): Promise<Project> {
|
||||
return apiRequest<Project>(`/projects/${projectId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
|
|
@ -203,8 +210,8 @@ export async function createProjectFolder(
|
|||
projectId: string,
|
||||
name: string,
|
||||
parentFolderId?: string | null,
|
||||
): Promise<MikeFolder> {
|
||||
return apiRequest<MikeFolder>(`/projects/${projectId}/folders`, {
|
||||
): Promise<Folder> {
|
||||
return apiRequest<Folder>(`/projects/${projectId}/folders`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
|
|
@ -218,8 +225,8 @@ export async function renameProjectFolder(
|
|||
projectId: string,
|
||||
folderId: string,
|
||||
name: string,
|
||||
): Promise<MikeFolder> {
|
||||
return apiRequest<MikeFolder>(
|
||||
): Promise<Folder> {
|
||||
return apiRequest<Folder>(
|
||||
`/projects/${projectId}/folders/${folderId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
|
|
@ -242,8 +249,8 @@ export async function moveSubfolderToFolder(
|
|||
projectId: string,
|
||||
folderId: string,
|
||||
parentFolderId: string | null,
|
||||
): Promise<MikeFolder> {
|
||||
return apiRequest<MikeFolder>(
|
||||
): Promise<Folder> {
|
||||
return apiRequest<Folder>(
|
||||
`/projects/${projectId}/folders/${folderId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
|
|
@ -257,8 +264,8 @@ export async function moveDocumentToFolder(
|
|||
projectId: string,
|
||||
documentId: string,
|
||||
folderId: string | null,
|
||||
): Promise<MikeDocument> {
|
||||
return apiRequest<MikeDocument>(
|
||||
): Promise<Document> {
|
||||
return apiRequest<Document>(
|
||||
`/projects/${projectId}/documents/${documentId}/folder`,
|
||||
{
|
||||
method: "PATCH",
|
||||
|
|
@ -272,8 +279,8 @@ export async function renameProjectDocument(
|
|||
projectId: string,
|
||||
documentId: string,
|
||||
filename: string,
|
||||
): Promise<MikeDocument> {
|
||||
return apiRequest<MikeDocument>(
|
||||
): Promise<Document> {
|
||||
return apiRequest<Document>(
|
||||
`/projects/${projectId}/documents/${documentId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
|
|
@ -286,24 +293,27 @@ export async function renameProjectDocument(
|
|||
export async function addDocumentToProject(
|
||||
projectId: string,
|
||||
documentId: string,
|
||||
): Promise<MikeDocument> {
|
||||
return apiRequest<MikeDocument>(
|
||||
): Promise<Document> {
|
||||
return apiRequest<Document>(
|
||||
`/projects/${projectId}/documents/${documentId}`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
export interface MikeDocumentVersion {
|
||||
export interface DocumentVersion {
|
||||
id: string;
|
||||
version_number: number | null;
|
||||
source: string;
|
||||
created_at: string;
|
||||
display_name: string | null;
|
||||
filename: string | null;
|
||||
file_type?: string | null;
|
||||
size_bytes?: number | null;
|
||||
page_count?: number | null;
|
||||
}
|
||||
|
||||
export async function listDocumentVersions(documentId: string): Promise<{
|
||||
current_version_id: string | null;
|
||||
versions: MikeDocumentVersion[];
|
||||
versions: DocumentVersion[];
|
||||
}> {
|
||||
return apiRequest(`/single-documents/${documentId}/versions`);
|
||||
}
|
||||
|
|
@ -311,12 +321,12 @@ export async function listDocumentVersions(documentId: string): Promise<{
|
|||
export async function uploadDocumentVersion(
|
||||
documentId: string,
|
||||
file: File,
|
||||
displayName?: string,
|
||||
): Promise<MikeDocumentVersion> {
|
||||
filename?: string,
|
||||
): Promise<DocumentVersion> {
|
||||
const authHeaders = await getAuthHeader();
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (displayName) form.append("display_name", displayName);
|
||||
if (filename) form.append("filename", filename);
|
||||
const response = await fetch(
|
||||
`${API_BASE}/single-documents/${documentId}/versions`,
|
||||
{
|
||||
|
|
@ -326,28 +336,58 @@ export async function uploadDocumentVersion(
|
|||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
return response.json() as Promise<MikeDocumentVersion>;
|
||||
return response.json() as Promise<DocumentVersion>;
|
||||
}
|
||||
|
||||
export async function copyDocumentVersionFromDocument(
|
||||
documentId: string,
|
||||
sourceDocumentId: string,
|
||||
filename?: string,
|
||||
): Promise<DocumentVersion> {
|
||||
return apiRequest<DocumentVersion>(
|
||||
`/single-documents/${documentId}/versions/from-document`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
source_document_id: sourceDocumentId,
|
||||
filename,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function renameDocumentVersion(
|
||||
documentId: string,
|
||||
versionId: string,
|
||||
displayName: string | null,
|
||||
): Promise<MikeDocumentVersion> {
|
||||
return apiRequest<MikeDocumentVersion>(
|
||||
filename: string | null,
|
||||
): Promise<DocumentVersion> {
|
||||
return apiRequest<DocumentVersion>(
|
||||
`/single-documents/${documentId}/versions/${versionId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ display_name: displayName }),
|
||||
body: JSON.stringify({ filename }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteDocumentVersion(
|
||||
documentId: string,
|
||||
versionId: string,
|
||||
): Promise<{
|
||||
deleted_version_id: string;
|
||||
current_version_id: string | null;
|
||||
}> {
|
||||
return apiRequest(`/single-documents/${documentId}/versions/${versionId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadProjectDocument(
|
||||
projectId: string,
|
||||
file: File,
|
||||
): Promise<MikeDocument> {
|
||||
): Promise<Document> {
|
||||
const authHeaders = await getAuthHeader();
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
|
|
@ -360,12 +400,12 @@ export async function uploadProjectDocument(
|
|||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
return response.json() as Promise<MikeDocument>;
|
||||
return response.json() as Promise<Document>;
|
||||
}
|
||||
|
||||
export async function uploadStandaloneDocument(
|
||||
file: File,
|
||||
): Promise<MikeDocument> {
|
||||
): Promise<Document> {
|
||||
const authHeaders = await getAuthHeader();
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
|
|
@ -375,11 +415,11 @@ export async function uploadStandaloneDocument(
|
|||
body: form,
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
return response.json() as Promise<MikeDocument>;
|
||||
return response.json() as Promise<Document>;
|
||||
}
|
||||
|
||||
export async function listStandaloneDocuments(): Promise<MikeDocument[]> {
|
||||
return apiRequest<MikeDocument[]>("/single-documents");
|
||||
export async function listStandaloneDocuments(): Promise<Document[]> {
|
||||
return apiRequest<Document[]>("/single-documents");
|
||||
}
|
||||
|
||||
export async function deleteDocument(documentId: string): Promise<void> {
|
||||
|
|
@ -428,20 +468,20 @@ export async function createChat(payload?: {
|
|||
});
|
||||
}
|
||||
|
||||
export async function listChats(options?: { limit?: number }): Promise<MikeChat[]> {
|
||||
export async function listChats(options?: { limit?: number }): Promise<Chat[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit) params.set("limit", String(options.limit));
|
||||
const query = params.toString();
|
||||
return apiRequest<MikeChat[]>(`/chat${query ? `?${query}` : ""}`);
|
||||
return apiRequest<Chat[]>(`/chat${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function listProjectChats(projectId: string): Promise<MikeChat[]> {
|
||||
return apiRequest<MikeChat[]>(`/projects/${projectId}/chats`);
|
||||
export async function listProjectChats(projectId: string): Promise<Chat[]> {
|
||||
return apiRequest<Chat[]>(`/projects/${projectId}/chats`);
|
||||
}
|
||||
|
||||
export async function getChat(chatId: string): Promise<MikeChatDetailOut> {
|
||||
export async function getChat(chatId: string): Promise<ChatDetailOut> {
|
||||
const raw = await apiRequest<ServerChatDetailOut>(`/chat/${chatId}`);
|
||||
const messages: MikeMessage[] = raw.messages.map((m) => {
|
||||
const messages: Message[] = raw.messages.map((m) => {
|
||||
if (m.role === "user") {
|
||||
return {
|
||||
role: "user",
|
||||
|
|
@ -490,6 +530,32 @@ export async function generateChatTitle(
|
|||
});
|
||||
}
|
||||
|
||||
export type CaseLawOpinion = {
|
||||
opinionId: number | null;
|
||||
apiUrl?: string | null;
|
||||
type: string | null;
|
||||
author: string | null;
|
||||
url: string | null;
|
||||
text?: string | null;
|
||||
html?: string | null;
|
||||
};
|
||||
|
||||
export async function getCourtlistenerOpinions(
|
||||
clusterId: number,
|
||||
): Promise<CaseLawOpinion[]> {
|
||||
const result = await apiRequest<{ opinions: CaseLawOpinion[] }>(
|
||||
"/case-law/case-opinions",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
clusterId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
return result.opinions;
|
||||
}
|
||||
|
||||
export async function streamChat(payload: {
|
||||
messages: {
|
||||
role: string;
|
||||
|
|
@ -627,7 +693,7 @@ export async function uploadReviewDocument(
|
|||
documentIds?: string[];
|
||||
columnsConfig?: { index: number; name: string; prompt: string }[];
|
||||
},
|
||||
): Promise<MikeDocument> {
|
||||
): Promise<Document> {
|
||||
const uploaded = options?.projectId
|
||||
? await uploadProjectDocument(options.projectId, file)
|
||||
: await uploadStandaloneDocument(file);
|
||||
|
|
@ -789,16 +855,16 @@ export async function clearTabularCells(
|
|||
// Workflows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type WorkflowType = MikeWorkflow["type"];
|
||||
type WorkflowType = Workflow["type"];
|
||||
|
||||
export async function listWorkflows(
|
||||
type: WorkflowType,
|
||||
): Promise<MikeWorkflow[]> {
|
||||
return apiRequest<MikeWorkflow[]>(`/workflows?type=${type}`);
|
||||
): Promise<Workflow[]> {
|
||||
return apiRequest<Workflow[]>(`/workflows?type=${type}`);
|
||||
}
|
||||
|
||||
export async function getWorkflow(workflowId: string): Promise<MikeWorkflow> {
|
||||
return apiRequest<MikeWorkflow>(`/workflows/${workflowId}`);
|
||||
export async function getWorkflow(workflowId: string): Promise<Workflow> {
|
||||
return apiRequest<Workflow>(`/workflows/${workflowId}`);
|
||||
}
|
||||
|
||||
export async function createWorkflow(payload: {
|
||||
|
|
@ -807,8 +873,8 @@ export async function createWorkflow(payload: {
|
|||
prompt_md?: string;
|
||||
columns_config?: { index: number; name: string; prompt: string }[];
|
||||
practice?: string | null;
|
||||
}): Promise<MikeWorkflow> {
|
||||
return apiRequest<MikeWorkflow>("/workflows", {
|
||||
}): Promise<Workflow> {
|
||||
return apiRequest<Workflow>("/workflows", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
|
|
@ -823,8 +889,8 @@ export async function updateWorkflow(
|
|||
columns_config?: { index: number; name: string; prompt: string }[];
|
||||
practice?: string | null;
|
||||
},
|
||||
): Promise<MikeWorkflow> {
|
||||
return apiRequest<MikeWorkflow>(`/workflows/${workflowId}`, {
|
||||
): Promise<Workflow> {
|
||||
return apiRequest<Workflow>(`/workflows/${workflowId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { MODELS, type ModelOption } from "../components/assistant/ModelToggle";
|
||||
import { SETTINGS_MODELS, type ModelOption } from "../components/assistant/ModelToggle";
|
||||
import type { ApiKeyState } from "@/app/lib/mikeApi";
|
||||
|
||||
export type ModelProvider = "claude" | "gemini" | "openai";
|
||||
|
||||
export function getModelProvider(modelId: string): ModelProvider | null {
|
||||
const model = MODELS.find((m) => m.id === modelId);
|
||||
const model = SETTINGS_MODELS.find((m) => m.id === modelId);
|
||||
if (!model) return null;
|
||||
return modelGroupToProvider(model.group);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ interface UserProfile {
|
|||
creditsResetDate: string;
|
||||
creditsRemaining: number;
|
||||
tier: string;
|
||||
titleModel: string;
|
||||
tabularModel: string;
|
||||
apiKeys: ApiKeyState;
|
||||
}
|
||||
|
|
@ -35,7 +36,7 @@ interface UserProfileContextType {
|
|||
updateDisplayName: (name: string) => Promise<boolean>;
|
||||
updateOrganisation: (organisation: string) => Promise<boolean>;
|
||||
updateModelPreference: (
|
||||
field: "tabularModel",
|
||||
field: "titleModel" | "tabularModel",
|
||||
value: string,
|
||||
) => Promise<boolean>;
|
||||
updateApiKey: (
|
||||
|
|
@ -50,13 +51,21 @@ const UserProfileContext = createContext<UserProfileContextType | undefined>(
|
|||
undefined,
|
||||
);
|
||||
|
||||
const API_KEY_PROVIDERS: ApiKeyProvider[] = ["claude", "gemini", "openai"];
|
||||
const API_KEY_PROVIDERS: ApiKeyProvider[] = [
|
||||
"claude",
|
||||
"gemini",
|
||||
"openai",
|
||||
"openrouter",
|
||||
"courtlistener",
|
||||
];
|
||||
|
||||
function emptyApiKeys(): ApiKeyState {
|
||||
return {
|
||||
claude: { configured: false, source: null },
|
||||
gemini: { configured: false, source: null },
|
||||
openai: { configured: false, source: null },
|
||||
openrouter: { configured: false, source: null },
|
||||
courtlistener: { configured: false, source: null },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +109,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
|
|||
creditsResetDate: futureResetDate.toISOString(),
|
||||
creditsRemaining: 999999, // temporarily unlimited
|
||||
tier: "Free",
|
||||
titleModel: "gemini-3.1-flash-lite-preview",
|
||||
tabularModel: "gemini-3-flash-preview",
|
||||
apiKeys: emptyApiKeys(),
|
||||
});
|
||||
|
|
@ -154,12 +164,14 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
|
|||
);
|
||||
|
||||
const updateModelPreference = useCallback(
|
||||
async (field: "tabularModel", value: string): Promise<boolean> => {
|
||||
async (
|
||||
field: "titleModel" | "tabularModel",
|
||||
value: string,
|
||||
): Promise<boolean> => {
|
||||
if (!user) return false;
|
||||
if (field !== "tabularModel") return false;
|
||||
try {
|
||||
const updated = await updateUserProfile({
|
||||
tabularModel: value,
|
||||
[field]: value,
|
||||
});
|
||||
setProfile((prev) =>
|
||||
prev ? { ...prev, ...toProfile(updated) } : null,
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ export async function getUserFromRequest(request: NextRequest): Promise<{
|
|||
return null;
|
||||
}
|
||||
|
||||
console.log(`[Auth] User authenticated: ${user.email}`);
|
||||
return {
|
||||
email: user.email,
|
||||
id: user.id
|
||||
|
|
@ -53,4 +52,3 @@ export async function getUserFromRequest(request: NextRequest): Promise<{
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,132 +0,0 @@
|
|||
/**
|
||||
* Cloudflare R2 storage utilities for Mike document management.
|
||||
* R2 is S3-compatible — uses @aws-sdk/client-s3.
|
||||
*
|
||||
* Required env vars:
|
||||
* R2_ENDPOINT_URL — https://<account-id>.r2.cloudflarestorage.com
|
||||
* R2_ACCESS_KEY_ID — R2 API token (Access Key ID)
|
||||
* R2_SECRET_ACCESS_KEY — R2 API token (Secret Access Key)
|
||||
* R2_BUCKET_NAME — bucket name (default: "mike")
|
||||
*/
|
||||
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl as awsGetSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
function getClient(): S3Client {
|
||||
return new S3Client({
|
||||
region: "auto",
|
||||
endpoint: process.env.R2_ENDPOINT_URL!,
|
||||
credentials: {
|
||||
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const BUCKET = process.env.R2_BUCKET_NAME ?? "mike";
|
||||
|
||||
export const storageEnabled = Boolean(
|
||||
process.env.R2_ENDPOINT_URL &&
|
||||
process.env.R2_ACCESS_KEY_ID &&
|
||||
process.env.R2_SECRET_ACCESS_KEY,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function uploadFile(
|
||||
key: string,
|
||||
content: ArrayBuffer,
|
||||
contentType: string,
|
||||
): Promise<void> {
|
||||
const client = getClient();
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: key,
|
||||
Body: Buffer.from(content),
|
||||
ContentType: contentType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Download
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function downloadFile(key: string): Promise<ArrayBuffer | null> {
|
||||
if (!storageEnabled) return null;
|
||||
try {
|
||||
const client = getClient();
|
||||
const response = await client.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: key }),
|
||||
);
|
||||
if (!response.Body) return null;
|
||||
const bytes = await response.Body.transformToByteArray();
|
||||
return bytes.buffer as ArrayBuffer;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function deleteFile(key: string): Promise<void> {
|
||||
if (!storageEnabled) return;
|
||||
const client = getClient();
|
||||
await client.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signed URL (pre-signed for temporary direct access)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function getSignedUrl(
|
||||
key: string,
|
||||
expiresIn = 3600,
|
||||
): Promise<string | null> {
|
||||
if (!storageEnabled) return null;
|
||||
try {
|
||||
const client = getClient();
|
||||
const command = new GetObjectCommand({ Bucket: BUCKET, Key: key });
|
||||
return await awsGetSignedUrl(client, command, { expiresIn });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Storage key helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function storageKey(
|
||||
userId: string,
|
||||
docId: string,
|
||||
filename: string,
|
||||
): string {
|
||||
return `documents/${userId}/${docId}/${filename}`;
|
||||
}
|
||||
|
||||
export function pdfStorageKey(
|
||||
userId: string,
|
||||
docId: string,
|
||||
stem: string,
|
||||
): string {
|
||||
return `documents/${userId}/${docId}/${stem}.pdf`;
|
||||
}
|
||||
|
||||
export function generatedDocKey(
|
||||
userId: string,
|
||||
docId: string,
|
||||
filename: string,
|
||||
): string {
|
||||
return `generated/${userId}/${docId}/${filename}`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue