mirror of
https://github.com/willchen96/mike.git
synced 2026-06-10 20:35:12 +02:00
feat: add OpenAI model support and harden OSS security defaults
This commit is contained in:
parent
adc2cf2370
commit
bef75b082d
24 changed files with 1301 additions and 364 deletions
|
|
@ -44,38 +44,58 @@ export default function AccountLayout({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full md:overflow-y-auto px-6 py-6 md:py-10">
|
||||
<div className="max-w-5xl w-full mx-auto">
|
||||
<h1 className="text-4xl font-medium mb-8 font-eb-garamond">
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
<header className="mx-auto flex h-16 w-full max-w-5xl shrink-0 items-end px-6 pb-2 md:h-24 md:pb-4">
|
||||
<h1 className="text-4xl font-medium font-eb-garamond">
|
||||
Settings
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-6 md:gap-10">
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-6 pb-10 pt-4 md:pt-6">
|
||||
<div className="grid grid-cols-1 gap-y-6 md:grid-cols-[224px_minmax(0,1fr)] md:gap-x-10">
|
||||
<nav
|
||||
aria-label="Settings"
|
||||
className="md:w-56 shrink-0 flex md:flex-col gap-1 overflow-x-auto"
|
||||
className="z-10 -ml-3 min-w-0 self-start md:sticky md:top-4"
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const active = pathname === tab.href;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => router.push(tab.href)}
|
||||
className={`text-left whitespace-nowrap px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
active
|
||||
? "bg-gray-100 text-gray-900"
|
||||
: "text-gray-500 hover:text-gray-900 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="-m-1 min-w-0 p-1">
|
||||
<div className="-m-1 min-w-0 overflow-x-auto overflow-y-hidden p-1">
|
||||
<ul className="mb-0 flex gap-1 md:flex-col">
|
||||
{TABS.map((tab) => {
|
||||
const active =
|
||||
pathname === tab.href ||
|
||||
(tab.href !== "/account" &&
|
||||
pathname.startsWith(tab.href));
|
||||
return (
|
||||
<li key={tab.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={
|
||||
active
|
||||
? "page"
|
||||
: undefined
|
||||
}
|
||||
onClick={() =>
|
||||
router.push(tab.href)
|
||||
}
|
||||
className={`flex h-9 w-full items-center rounded-lg px-3 text-left text-sm font-medium whitespace-nowrap transition-colors ${
|
||||
active
|
||||
? "bg-gray-100 text-gray-900"
|
||||
: "text-gray-500 hover:bg-gray-50 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="flex-1 min-w-0">{children}</div>
|
||||
<div className="min-w-0 outline-none">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,12 +13,32 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} 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 {
|
||||
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;
|
||||
|
||||
export default function ModelsAndApiKeysPage() {
|
||||
const { profile, updateModelPreference, updateApiKey } = useUserProfile();
|
||||
|
||||
|
|
@ -36,15 +56,16 @@ export default function ModelsAndApiKeysPage() {
|
|||
<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={{
|
||||
claudeApiKey: profile?.claudeApiKey ?? null,
|
||||
geminiApiKey: profile?.geminiApiKey ?? null,
|
||||
}}
|
||||
apiKeys={profile?.apiKeys}
|
||||
onChange={(id) =>
|
||||
updateModelPreference("tabularModel", id)
|
||||
}
|
||||
|
|
@ -66,29 +87,33 @@ export default function ModelsAndApiKeysPage() {
|
|||
own instance of Mike.
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mb-4 max-w-xl">
|
||||
Title generation automatically routes to the cheapest model
|
||||
of whichever provider you’ve configured (Gemini Flash
|
||||
Lite if a Gemini key is set, otherwise Claude Haiku).
|
||||
Title generation automatically routes to the cheapest
|
||||
configured provider model.
|
||||
</p>
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<ApiKeyField
|
||||
label="Anthropic (Claude) API Key"
|
||||
placeholder="sk-ant-…"
|
||||
hasSavedKey={!!profile?.claudeApiKey}
|
||||
onSave={(value) =>
|
||||
updateApiKey("claude", value.trim() || null)
|
||||
}
|
||||
onRemove={() => updateApiKey("claude", null)}
|
||||
/>
|
||||
<ApiKeyField
|
||||
label="Google (Gemini) API Key"
|
||||
placeholder="AI…"
|
||||
hasSavedKey={!!profile?.geminiApiKey}
|
||||
onSave={(value) =>
|
||||
updateApiKey("gemini", value.trim() || null)
|
||||
}
|
||||
onRemove={() => updateApiKey("gemini", null)}
|
||||
/>
|
||||
{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>
|
||||
</div>
|
||||
|
|
@ -102,12 +127,16 @@ function TabularModelDropdown({
|
|||
}: {
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null };
|
||||
apiKeys?: ApiKeyState;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const selected = MODELS.find((m) => m.id === value);
|
||||
const selectedAvailable = isModelAvailable(value, apiKeys);
|
||||
const groups: ("Anthropic" | "Google")[] = ["Anthropic", "Google"];
|
||||
const selectedAvailable = apiKeys ? isModelAvailable(value, apiKeys) : true;
|
||||
const groups: ("Anthropic" | "Google" | "OpenAI")[] = [
|
||||
"Anthropic",
|
||||
"Google",
|
||||
"OpenAI",
|
||||
];
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={setIsOpen}>
|
||||
|
|
@ -145,10 +174,9 @@ function TabularModelDropdown({
|
|||
</DropdownMenuLabel>
|
||||
{items.map((m) => {
|
||||
const provider = modelGroupToProvider(m.group);
|
||||
const available = isModelAvailable(
|
||||
m.id,
|
||||
apiKeys,
|
||||
);
|
||||
const available = apiKeys
|
||||
? isModelAvailable(m.id, apiKeys)
|
||||
: true;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={m.id}
|
||||
|
|
@ -156,7 +184,7 @@ function TabularModelDropdown({
|
|||
onSelect={() => onChange(m.id)}
|
||||
title={
|
||||
!available
|
||||
? `Add a ${provider === "claude" ? "Claude" : "Gemini"} API key to use this model`
|
||||
? `Add a ${providerLabel(provider)} API key to use this model`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
|
|
@ -186,12 +214,14 @@ function ApiKeyField({
|
|||
label,
|
||||
placeholder,
|
||||
hasSavedKey,
|
||||
isServerConfigured,
|
||||
onSave,
|
||||
onRemove,
|
||||
}: {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
hasSavedKey: boolean;
|
||||
isServerConfigured: boolean;
|
||||
onSave: (value: string) => Promise<boolean>;
|
||||
onRemove: () => Promise<boolean>;
|
||||
}) {
|
||||
|
|
@ -229,7 +259,20 @@ function ApiKeyField({
|
|||
return (
|
||||
<div>
|
||||
<label className="text-sm text-gray-600 block mb-2">{label}</label>
|
||||
{hasSavedKey && (
|
||||
{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>
|
||||
|
|
@ -240,15 +283,23 @@ function ApiKeyField({
|
|||
type={reveal ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={hasSavedKey ? "Saved key hidden" : placeholder}
|
||||
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)}
|
||||
className="absolute inset-y-0 right-2 flex items-center text-gray-400 hover:text-gray-600"
|
||||
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 ? (
|
||||
|
|
@ -260,7 +311,7 @@ function ApiKeyField({
|
|||
</div>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !dirty || saved}
|
||||
disabled={isServerConfigured || isSaving || !dirty || saved}
|
||||
className="min-w-[80px] transition-all bg-black hover:bg-gray-900 text-white"
|
||||
>
|
||||
{isSaving ? (
|
||||
|
|
@ -274,7 +325,7 @@ function ApiKeyField({
|
|||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
{hasSavedKey && (
|
||||
{hasSavedKey && !isServerConfigured && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,19 @@ import { EditCard, applyOptimisticResolution } from "./EditCard";
|
|||
import { PreResponseWrapper } from "../shared/PreResponseWrapper";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
function toolCallLabel(name: string): string {
|
||||
if (name === "generate_docx") return "Creating document...";
|
||||
if (name === "edit_document") return "Editing document...";
|
||||
if (name === "read_document") return "Reading document...";
|
||||
if (name === "fetch_documents") return "Reading documents...";
|
||||
if (name === "find_in_document") return "Searching document...";
|
||||
if (name === "replicate_document") return "Copying document...";
|
||||
if (name === "read_workflow") return "Loading workflow...";
|
||||
if (name === "list_workflows") return "Loading workflows...";
|
||||
if (name === "list_documents") return "Loading documents...";
|
||||
return name ? `Running ${name}...` : "Working...";
|
||||
}
|
||||
|
||||
/**
|
||||
* Card rendered above the per-edit EditCards when a message produced
|
||||
* multiple tracked-change proposals. Lets the user resolve every pending
|
||||
|
|
@ -1237,9 +1250,8 @@ export function AssistantMessage({
|
|||
<div className="absolute bottom-0 w-[1px] bg-gray-300 top-[13px] left-[2.5px] h-[calc(100%+11px)]" />
|
||||
)}
|
||||
<div className="w-1.5 h-1.5 rounded-full border border-gray-400 border-t-transparent animate-spin shrink-0" />
|
||||
<span className="font-medium ml-2">Running</span>
|
||||
<span className="ml-1">
|
||||
{event.name ? `${event.name}...` : "tool..."}
|
||||
<span className="font-medium ml-2">
|
||||
{toolCallLabel(event.name)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -67,12 +67,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
} | null>(null);
|
||||
const [model, setModel] = useSelectedModel();
|
||||
const { profile } = useUserProfile();
|
||||
const apiKeys = profile
|
||||
? {
|
||||
claudeApiKey: profile?.claudeApiKey ?? null,
|
||||
geminiApiKey: profile?.geminiApiKey ?? null,
|
||||
}
|
||||
: undefined;
|
||||
const apiKeys = profile?.apiKeys;
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [docSelectorOpen, setDocSelectorOpen] = useState(false);
|
||||
const [workflowModalOpen, setWorkflowModalOpen] = useState(false);
|
||||
|
|
|
|||
|
|
@ -11,11 +11,12 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { isModelAvailable } from "@/app/lib/modelAvailability";
|
||||
import type { ApiKeyState } from "@/app/lib/mikeApi";
|
||||
|
||||
export interface ModelOption {
|
||||
id: string;
|
||||
label: string;
|
||||
group: "Anthropic" | "Google";
|
||||
group: "Anthropic" | "Google" | "OpenAI";
|
||||
}
|
||||
|
||||
export const MODELS: ModelOption[] = [
|
||||
|
|
@ -23,21 +24,20 @@ export const MODELS: ModelOption[] = [
|
|||
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", group: "Anthropic" },
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
export const DEFAULT_MODEL_ID = "gemini-3-flash-preview";
|
||||
|
||||
export const ALLOWED_MODEL_IDS = new Set(MODELS.map((m) => m.id));
|
||||
|
||||
const GROUP_ORDER: ModelOption["group"][] = ["Anthropic", "Google"];
|
||||
const GROUP_ORDER: ModelOption["group"][] = ["Anthropic", "Google", "OpenAI"];
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
apiKeys?: {
|
||||
claudeApiKey: string | null;
|
||||
geminiApiKey: string | null;
|
||||
};
|
||||
apiKeys?: ApiKeyState;
|
||||
}
|
||||
|
||||
export function ModelToggle({ value, onChange, apiKeys }: Props) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { type CSSProperties, useEffect, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
Upload,
|
||||
|
|
@ -54,7 +54,11 @@ import type {
|
|||
} from "@/app/components/shared/types";
|
||||
import { ToolbarTabs } from "@/app/components/shared/ToolbarTabs";
|
||||
import { RenameableTitle } from "@/app/components/shared/RenameableTitle";
|
||||
import { RowActions } from "@/app/components/shared/RowActions";
|
||||
import {
|
||||
closeRowActionMenus,
|
||||
RowActionMenuItems,
|
||||
RowActions,
|
||||
} from "@/app/components/shared/RowActions";
|
||||
import { AddDocumentsModal } from "@/app/components/shared/AddDocumentsModal";
|
||||
import { PeopleModal } from "@/app/components/shared/PeopleModal";
|
||||
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
|
||||
|
|
@ -74,12 +78,35 @@ type Tab = "documents" | "assistant" | "reviews";
|
|||
type ContextMenu = {
|
||||
x: number;
|
||||
y: number;
|
||||
docId?: string | null;
|
||||
folderId: string | null; // null = right-clicked on root/empty space
|
||||
showFolderActions: boolean; // true when right-clicked on a specific folder row
|
||||
};
|
||||
|
||||
const CHECK_W = "w-8 shrink-0";
|
||||
const NAME_COL_W = "w-[300px] 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);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function treeNameCellStyle(depth: number): CSSProperties | undefined {
|
||||
if (depth <= 0) return undefined;
|
||||
return { left: treeControlWidth(depth) };
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
|
|
@ -113,6 +140,7 @@ function DocVersionHistory({
|
|||
filename,
|
||||
loading,
|
||||
versions,
|
||||
depth = 0,
|
||||
onDownloadVersion,
|
||||
onOpenVersion,
|
||||
onRenameVersion,
|
||||
|
|
@ -121,6 +149,7 @@ function DocVersionHistory({
|
|||
filename: string;
|
||||
loading: boolean;
|
||||
versions: MikeDocumentVersion[];
|
||||
depth?: number;
|
||||
onDownloadVersion: (
|
||||
docId: string,
|
||||
versionId: string,
|
||||
|
|
@ -150,8 +179,8 @@ function DocVersionHistory({
|
|||
if (loading && versions.length === 0) {
|
||||
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`} />
|
||||
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-gray-50/60 p-2`}>
|
||||
<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] ${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>
|
||||
|
|
@ -163,8 +192,8 @@ function DocVersionHistory({
|
|||
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={`sticky left-0 z-[60] ${CHECK_W} bg-gray-50/60 self-stretch`} />
|
||||
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-gray-50/60 p-2`}>
|
||||
<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] ${NAME_COL_W} bg-gray-50/60 p-2`} style={treeNameCellStyle(depth)}>
|
||||
<div>
|
||||
No version history.
|
||||
</div>
|
||||
|
|
@ -204,8 +233,8 @@ function DocVersionHistory({
|
|||
}}
|
||||
className="group flex items-center h-9 pr-8 border-b border-gray-50 bg-gray-50/60 text-xs text-gray-600 cursor-pointer hover:bg-gray-100/80 transition-colors"
|
||||
>
|
||||
<div className={`sticky left-0 z-[60] ${CHECK_W} bg-gray-50/60 group-hover:bg-gray-100/80 self-stretch`} />
|
||||
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-gray-50/60 group-hover:bg-gray-100/80 p-2`}>
|
||||
<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] ${NAME_COL_W} bg-gray-50/60 group-hover:bg-gray-100/80 p-2`} style={treeNameCellStyle(depth)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="shrink-0 text-gray-400">↳</span>
|
||||
{isEditing ? (
|
||||
|
|
@ -846,7 +875,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
|
||||
// ── Tree rendering ────────────────────────────────────────────────────────
|
||||
|
||||
function renderFolderInput(parentId: string | null) {
|
||||
function renderFolderInput(parentId: string | null, depth: number) {
|
||||
if (creatingFolderIn !== parentId) return null;
|
||||
return (
|
||||
<div
|
||||
|
|
@ -854,10 +883,17 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
className="group flex items-center h-10 pr-8 border-b border-gray-50"
|
||||
key={`new-folder-${parentId ?? "root"}`}
|
||||
>
|
||||
<div className={`sticky left-0 z-[60] ${CHECK_W} bg-white self-stretch`} />
|
||||
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2`}>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} bg-white p-2 flex items-center justify-center self-stretch`}
|
||||
style={treeControlCellStyle(depth)}
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5 text-gray-300 shrink-0" />
|
||||
</div>
|
||||
<div
|
||||
className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2`}
|
||||
style={treeNameCellStyle(depth)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ChevronRight className="h-3.5 w-3.5 text-gray-300 shrink-0" />
|
||||
<FolderPlus className="h-4 w-4 text-amber-400 shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
|
|
@ -911,7 +947,18 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
setViewingDocVersion(null);
|
||||
setViewingDoc(doc);
|
||||
}}
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeRowActionMenus();
|
||||
setContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
docId: doc.id,
|
||||
folderId: null,
|
||||
showFolderActions: false,
|
||||
});
|
||||
}}
|
||||
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
>
|
||||
{(() => {
|
||||
|
|
@ -922,6 +969,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
<>
|
||||
<div
|
||||
className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${rowBg} group-hover:bg-gray-50`}
|
||||
style={treeControlCellStyle(depth)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
|
|
@ -937,7 +985,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: 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} p-2 ${rowBg} group-hover:bg-gray-50`}>
|
||||
<div className={`sticky left-8 z-[60] ${NAME_COL_W} p-2 ${rowBg} group-hover:bg-gray-50`} style={treeNameCellStyle(depth)}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isProcessing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" />
|
||||
|
|
@ -1008,6 +1056,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
filename={doc.filename}
|
||||
loading={loadingVersionDocIds.has(doc.id)}
|
||||
versions={versionsByDocId.get(doc.id) ?? []}
|
||||
depth={depth}
|
||||
onDownloadVersion={downloadDocVersion}
|
||||
onOpenVersion={(versionId, label) => {
|
||||
setViewingDocVersion({ id: versionId, label });
|
||||
|
|
@ -1042,17 +1091,18 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeRowActionMenus();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, folderId: folder.id, showFolderActions: true });
|
||||
}}
|
||||
className={`group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors select-none ${dragOverFolderId === folder.id ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : ""}`}
|
||||
>
|
||||
<div className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${dragOverFolderId === folder.id ? "bg-blue-50" : "bg-white"} group-hover:bg-gray-50 self-stretch`}>
|
||||
<div className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${dragOverFolderId === folder.id ? "bg-blue-50" : "bg-white"} group-hover:bg-gray-50 self-stretch`} style={treeControlCellStyle(depth)}>
|
||||
{isExpanded
|
||||
? <ChevronDown className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||
: <ChevronRight className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||
}
|
||||
</div>
|
||||
<div className={`sticky left-8 z-[60] ${NAME_COL_W} p-2 ${dragOverFolderId === folder.id ? "bg-blue-50" : "bg-white"} group-hover:bg-gray-50`}>
|
||||
<div className={`sticky left-8 z-[60] ${NAME_COL_W} p-2 ${dragOverFolderId === folder.id ? "bg-blue-50" : "bg-white"} group-hover:bg-gray-50`} style={treeNameCellStyle(depth)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isExpanded
|
||||
? <FolderOpen className="h-4 w-4 text-amber-500 shrink-0" />
|
||||
|
|
@ -1100,7 +1150,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
})}
|
||||
|
||||
{/* New-folder input row at the bottom of this level */}
|
||||
{renderFolderInput(parentId)}
|
||||
{renderFolderInput(parentId, depth)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1187,7 +1237,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{actionsOpen && (
|
||||
<div className="absolute top-full right-0 mt-1 w-36 rounded-lg border border-gray-100 bg-white shadow-lg z-50 overflow-hidden">
|
||||
<div className="absolute top-full right-0 mt-1 w-36 rounded-lg border border-gray-100 bg-white shadow-lg z-[120] overflow-hidden">
|
||||
{tab === "documents" && (
|
||||
<button
|
||||
onClick={handleDownloadSelectedDocs}
|
||||
|
|
@ -1365,7 +1415,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
{/* Blue ring wraps everything below the header when root-dropping */}
|
||||
<div className="flex-1 flex flex-col min-h-0 relative">
|
||||
{dragOverRoot && dragOverFolderId === null && (
|
||||
<div className="absolute inset-0 border-2 border-blue-400 pointer-events-none z-20" />
|
||||
<div className="absolute inset-0 border-2 border-blue-400 pointer-events-none z-[80]" />
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
|
|
@ -1382,6 +1432,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
className="flex-1 flex flex-col"
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
closeRowActionMenus();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, folderId: null, showFolderActions: false });
|
||||
}}
|
||||
onClick={() => setContextMenu(null)}
|
||||
|
|
@ -1414,6 +1465,18 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
setViewingDocVersion(null);
|
||||
setViewingDoc(doc);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeRowActionMenus();
|
||||
setContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
docId: doc.id,
|
||||
folderId: null,
|
||||
showFolderActions: false,
|
||||
});
|
||||
}}
|
||||
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${selectedDocIds.includes(doc.id) ? "bg-gray-50" : "bg-white"} group-hover:bg-gray-50`} onClick={(e) => e.stopPropagation()}>
|
||||
|
|
@ -1503,51 +1566,107 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
|||
)}
|
||||
|
||||
{/* Context menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="fixed z-50 w-44 rounded-lg border border-gray-100 bg-white shadow-lg overflow-hidden text-xs"
|
||||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="w-full px-3 py-1.5 text-left text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
||||
onClick={() => {
|
||||
setCreatingFolderIn(contextMenu.folderId);
|
||||
setNewFolderName("");
|
||||
if (contextMenu.folderId) setExpandedFolderIds((prev) => new Set([...prev, contextMenu.folderId!]));
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
<FolderPlus className="h-3.5 w-3.5 text-gray-400" />
|
||||
{contextMenu.showFolderActions ? "New subfolder inside" : "New subfolder"}
|
||||
</button>
|
||||
{contextMenu.showFolderActions && contextMenu.folderId && (
|
||||
<>
|
||||
<button
|
||||
className="w-full px-3 py-1.5 text-left text-gray-700 hover:bg-gray-50"
|
||||
onClick={() => {
|
||||
const f = folders.find((x) => x.id === contextMenu.folderId);
|
||||
setRenameFolderValue(f?.name ?? "");
|
||||
setRenamingFolderId(contextMenu.folderId!);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
Rename folder
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-3 py-1.5 text-left text-red-600 hover:bg-red-50"
|
||||
onClick={() => {
|
||||
handleDeleteFolder(contextMenu.folderId!);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
Delete folder
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{contextMenu &&
|
||||
(() => {
|
||||
const menuDoc = contextMenu.docId
|
||||
? docs.find((doc) => doc.id === contextMenu.docId)
|
||||
: null;
|
||||
const menuDocHasVersions =
|
||||
typeof menuDoc?.latest_version_number === "number" &&
|
||||
menuDoc.latest_version_number >= 1;
|
||||
const menuDocVersionsOpen = menuDoc
|
||||
? expandedVersionDocIds.has(menuDoc.id)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="fixed z-[120] w-48 rounded-xl border border-gray-100 bg-white shadow-lg overflow-hidden"
|
||||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{menuDoc ? (
|
||||
<RowActionMenuItems
|
||||
onClose={() => setContextMenu(null)}
|
||||
onDownload={() => downloadDoc(menuDoc.id)}
|
||||
onShowAllVersions={
|
||||
menuDocHasVersions && !menuDocVersionsOpen
|
||||
? () => void toggleVersions(menuDoc.id)
|
||||
: undefined
|
||||
}
|
||||
onUploadNewVersion={() =>
|
||||
void handleUploadNewVersion(menuDoc)
|
||||
}
|
||||
onRemoveFromFolder={
|
||||
menuDoc.folder_id
|
||||
? () =>
|
||||
void handleRemoveDocFromFolder(
|
||||
menuDoc.id,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
onDelete={() =>
|
||||
void handleRemoveDoc(menuDoc.id)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<RowActionMenuItems
|
||||
onClose={() => setContextMenu(null)}
|
||||
onNewSubfolder={() => {
|
||||
setCreatingFolderIn(
|
||||
contextMenu.folderId,
|
||||
);
|
||||
setNewFolderName("");
|
||||
if (contextMenu.folderId) {
|
||||
setExpandedFolderIds(
|
||||
(prev) =>
|
||||
new Set([
|
||||
...prev,
|
||||
contextMenu.folderId!,
|
||||
]),
|
||||
);
|
||||
}
|
||||
}}
|
||||
newSubfolderLabel={
|
||||
contextMenu.showFolderActions
|
||||
? "New subfolder inside"
|
||||
: "New subfolder"
|
||||
}
|
||||
onRename={
|
||||
contextMenu.showFolderActions &&
|
||||
contextMenu.folderId
|
||||
? () => {
|
||||
const f =
|
||||
folders.find(
|
||||
(x) =>
|
||||
x.id ===
|
||||
contextMenu.folderId,
|
||||
);
|
||||
setRenameFolderValue(
|
||||
f?.name ?? "",
|
||||
);
|
||||
setRenamingFolderId(
|
||||
contextMenu.folderId!,
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
renameLabel="Rename folder"
|
||||
onDelete={
|
||||
contextMenu.showFolderActions &&
|
||||
contextMenu.folderId
|
||||
? () =>
|
||||
handleDeleteFolder(
|
||||
contextMenu.folderId!,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
deleteLabel="Delete folder"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
</div>{/* end blue ring wrapper */}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Download, Eye, EyeOff, FolderMinus, Hash, History, Pencil, Trash2, Upload } from "lucide-react";
|
||||
import {
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FolderMinus,
|
||||
FolderPlus,
|
||||
Hash,
|
||||
History,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
|
||||
const CLOSE_ROW_ACTIONS_EVENT = "mike:close-row-actions";
|
||||
|
||||
export function closeRowActionMenus() {
|
||||
document.dispatchEvent(new Event(CLOSE_ROW_ACTIONS_EVENT));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onDelete?: () => void;
|
||||
|
|
@ -11,12 +28,130 @@ interface Props {
|
|||
onRemoveFromFolder?: () => void;
|
||||
onShowAllVersions?: () => void;
|
||||
onUploadNewVersion?: () => void;
|
||||
onNewSubfolder?: () => void;
|
||||
deleting?: boolean;
|
||||
onRename?: () => void;
|
||||
onUpdateCmNumber?: () => void;
|
||||
newSubfolderLabel?: string;
|
||||
renameLabel?: string;
|
||||
deleteLabel?: string;
|
||||
}
|
||||
|
||||
export function RowActions({ onDelete, onHide, onUnhide, onDownload, onRemoveFromFolder, onShowAllVersions, onUploadNewVersion, deleting, onRename, onUpdateCmNumber }: Props) {
|
||||
export function RowActionMenuItems({
|
||||
onDelete,
|
||||
onHide,
|
||||
onUnhide,
|
||||
onDownload,
|
||||
onRemoveFromFolder,
|
||||
onShowAllVersions,
|
||||
onUploadNewVersion,
|
||||
onNewSubfolder,
|
||||
deleting,
|
||||
onRename,
|
||||
onUpdateCmNumber,
|
||||
newSubfolderLabel = "New subfolder",
|
||||
renameLabel = "Rename",
|
||||
deleteLabel = "Delete",
|
||||
onClose,
|
||||
}: Props & { onClose: () => void }) {
|
||||
return (
|
||||
<>
|
||||
{onNewSubfolder && (
|
||||
<button
|
||||
onClick={() => { onClose(); onNewSubfolder(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<FolderPlus className="h-3.5 w-3.5 shrink-0" />
|
||||
{newSubfolderLabel}
|
||||
</button>
|
||||
)}
|
||||
{onRename && (
|
||||
<button
|
||||
onClick={() => { onClose(); onRename(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
{renameLabel}
|
||||
</button>
|
||||
)}
|
||||
{onUpdateCmNumber && (
|
||||
<button
|
||||
onClick={() => { onClose(); onUpdateCmNumber(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Hash className="h-3.5 w-3.5" />
|
||||
Edit CM No.
|
||||
</button>
|
||||
)}
|
||||
{onDownload && (
|
||||
<button
|
||||
onClick={() => { onClose(); onDownload(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Download
|
||||
</button>
|
||||
)}
|
||||
{onShowAllVersions && (
|
||||
<button
|
||||
onClick={() => { onClose(); onShowAllVersions(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<History className="h-3.5 w-3.5 shrink-0" />
|
||||
Show all versions
|
||||
</button>
|
||||
)}
|
||||
{onUploadNewVersion && (
|
||||
<button
|
||||
onClick={() => { onClose(); onUploadNewVersion(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5 shrink-0" />
|
||||
Upload new version
|
||||
</button>
|
||||
)}
|
||||
{onRemoveFromFolder && (
|
||||
<button
|
||||
onClick={() => { onClose(); onRemoveFromFolder(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<FolderMinus className="h-3.5 w-3.5 shrink-0" />
|
||||
Remove from subfolder
|
||||
</button>
|
||||
)}
|
||||
{onUnhide && (
|
||||
<button
|
||||
onClick={() => { onClose(); onUnhide(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
Unhide
|
||||
</button>
|
||||
)}
|
||||
{onHide && (
|
||||
<button
|
||||
onClick={() => { onClose(); onHide(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
Hide
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => { onClose(); onDelete(); }}
|
||||
disabled={deleting}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-red-500 hover:bg-red-50 transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{deleteLabel}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RowActions(props: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [coords, setCoords] = useState({ top: 0, right: 0 });
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
|
|
@ -30,16 +165,33 @@ export function RowActions({ onDelete, onHide, onUnhide, onDownload, onRemoveFro
|
|||
return () => document.removeEventListener("click", handleClick);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleCloseRowActions() {
|
||||
setOpen(false);
|
||||
}
|
||||
document.addEventListener(CLOSE_ROW_ACTIONS_EVENT, handleCloseRowActions);
|
||||
return () =>
|
||||
document.removeEventListener(
|
||||
CLOSE_ROW_ACTIONS_EVENT,
|
||||
handleCloseRowActions,
|
||||
);
|
||||
}, []);
|
||||
|
||||
function handleToggle(e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (!open && btnRef.current) {
|
||||
if (open) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
closeRowActionMenus();
|
||||
if (btnRef.current) {
|
||||
const rect = btnRef.current.getBoundingClientRect();
|
||||
setCoords({
|
||||
top: rect.bottom + 4,
|
||||
right: window.innerWidth - rect.right,
|
||||
});
|
||||
}
|
||||
setOpen((o) => !o);
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -55,91 +207,13 @@ export function RowActions({ onDelete, onHide, onUnhide, onDownload, onRemoveFro
|
|||
{open && (
|
||||
<div
|
||||
style={{ position: "fixed", top: coords.top, right: coords.right }}
|
||||
className="z-50 w-48 rounded-xl border border-gray-100 bg-white shadow-lg overflow-hidden"
|
||||
className="z-[120] w-48 rounded-xl border border-gray-100 bg-white shadow-lg overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{onRename && (
|
||||
<button
|
||||
onClick={() => { setOpen(false); onRename(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Rename
|
||||
</button>
|
||||
)}
|
||||
{onUpdateCmNumber && (
|
||||
<button
|
||||
onClick={() => { setOpen(false); onUpdateCmNumber(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Hash className="h-3.5 w-3.5" />
|
||||
Edit CM No.
|
||||
</button>
|
||||
)}
|
||||
{onDownload && (
|
||||
<button
|
||||
onClick={() => { setOpen(false); onDownload(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Download
|
||||
</button>
|
||||
)}
|
||||
{onShowAllVersions && (
|
||||
<button
|
||||
onClick={() => { setOpen(false); onShowAllVersions(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<History className="h-3.5 w-3.5 shrink-0" />
|
||||
Show all versions
|
||||
</button>
|
||||
)}
|
||||
{onUploadNewVersion && (
|
||||
<button
|
||||
onClick={() => { setOpen(false); onUploadNewVersion(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5 shrink-0" />
|
||||
Upload new version
|
||||
</button>
|
||||
)}
|
||||
{onRemoveFromFolder && (
|
||||
<button
|
||||
onClick={() => { setOpen(false); onRemoveFromFolder(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<FolderMinus className="h-3.5 w-3.5 shrink-0" />
|
||||
Remove from subfolder
|
||||
</button>
|
||||
)}
|
||||
{onUnhide && (
|
||||
<button
|
||||
onClick={() => { setOpen(false); onUnhide(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
Unhide
|
||||
</button>
|
||||
)}
|
||||
{onHide && (
|
||||
<button
|
||||
onClick={() => { setOpen(false); onHide(); }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
Hide
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => { setOpen(false); onDelete(); }}
|
||||
disabled={deleting}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-red-500 hover:bg-red-50 transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<RowActionMenuItems
|
||||
{...props}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import {
|
|||
isModelAvailable,
|
||||
type ModelProvider,
|
||||
} from "@/app/lib/modelAvailability";
|
||||
import type { ApiKeyState } from "@/app/lib/mikeApi";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
|
|
@ -454,7 +455,7 @@ function TRChatInput({
|
|||
onCancel: () => void;
|
||||
model: string;
|
||||
onModelChange: (id: string) => void;
|
||||
apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null };
|
||||
apiKeys?: ApiKeyState;
|
||||
onHeightChange: (height: number) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
|
|
@ -642,10 +643,7 @@ export function TRChatPanel({
|
|||
onChatIdChange,
|
||||
}: Props) {
|
||||
const { profile, updateModelPreference } = useUserProfile();
|
||||
const apiKeys = {
|
||||
claudeApiKey: profile?.claudeApiKey ?? null,
|
||||
geminiApiKey: profile?.geminiApiKey ?? null,
|
||||
};
|
||||
const apiKeys = profile?.apiKeys;
|
||||
const currentModel = profile?.tabularModel ?? "gemini-3-flash-preview";
|
||||
const [apiKeyModalProvider, setApiKeyModalProvider] =
|
||||
useState<ModelProvider | null>(null);
|
||||
|
|
@ -993,7 +991,7 @@ export function TRChatPanel({
|
|||
|
||||
async function handleSubmit(trimmed: string) {
|
||||
if (!trimmed || isLoading) return;
|
||||
if (!isModelAvailable(currentModel, apiKeys)) {
|
||||
if (apiKeys && !isModelAvailable(currentModel, apiKeys)) {
|
||||
setApiKeyModalProvider(getModelProvider(currentModel));
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,10 +87,7 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
const tableRef = useRef<TRTableHandle>(null);
|
||||
const router = useRouter();
|
||||
const { profile } = useUserProfile();
|
||||
const apiKeys = {
|
||||
claudeApiKey: profile?.claudeApiKey ?? null,
|
||||
geminiApiKey: profile?.geminiApiKey ?? null,
|
||||
};
|
||||
const apiKeys = profile?.apiKeys;
|
||||
const tabularModel = profile?.tabularModel ?? "gemini-3-flash-preview";
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -243,7 +240,7 @@ export function TRView({ reviewId, projectId }: Props) {
|
|||
// If columns changed since last save, update the review first
|
||||
if (columns.length === 0) return;
|
||||
|
||||
if (!isModelAvailable(tabularModel, apiKeys)) {
|
||||
if (apiKeys && !isModelAvailable(tabularModel, apiKeys)) {
|
||||
setApiKeyModalProvider(getModelProvider(tabularModel));
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -250,11 +250,11 @@ export function useAssistantChat({
|
|||
const pushEvent = (event: AssistantEvent) => {
|
||||
finalizeStreamingContent();
|
||||
finalizeStreamingReasoning();
|
||||
// Drop any in-flight placeholder unless we're pushing one ourselves.
|
||||
let next = eventsRef.current;
|
||||
if (event.type !== "tool_call_start" && event.type !== "thinking") {
|
||||
next = next.filter((e) => !isStreamingPlaceholder(e));
|
||||
}
|
||||
// A real event, or a more specific placeholder such as
|
||||
// tool_call_start, should replace any generic "Thinking..." line.
|
||||
const next = eventsRef.current.filter(
|
||||
(e) => !isStreamingPlaceholder(e),
|
||||
);
|
||||
eventsRef.current = [...next, event];
|
||||
const snapshot = [...eventsRef.current];
|
||||
setMessages((prev) => {
|
||||
|
|
|
|||
|
|
@ -124,9 +124,18 @@ export async function updateUserProfile(payload: {
|
|||
});
|
||||
}
|
||||
|
||||
export type ApiKeyStatus = {
|
||||
claude: boolean;
|
||||
gemini: boolean;
|
||||
export type ApiKeyProvider = "claude" | "gemini" | "openai";
|
||||
export type ApiKeySource = "user" | "env" | null;
|
||||
export type ApiKeyState = Record<
|
||||
ApiKeyProvider,
|
||||
{
|
||||
configured: boolean;
|
||||
source: ApiKeySource;
|
||||
}
|
||||
>;
|
||||
|
||||
export type ApiKeyStatus = Record<ApiKeyProvider, boolean> & {
|
||||
sources?: Partial<Record<ApiKeyProvider, ApiKeySource>>;
|
||||
};
|
||||
|
||||
export async function getApiKeyStatus(): Promise<ApiKeyStatus> {
|
||||
|
|
@ -134,7 +143,7 @@ export async function getApiKeyStatus(): Promise<ApiKeyStatus> {
|
|||
}
|
||||
|
||||
export async function saveApiKey(
|
||||
provider: keyof ApiKeyStatus,
|
||||
provider: ApiKeyProvider,
|
||||
apiKey: string | null,
|
||||
): Promise<ApiKeyStatus> {
|
||||
return apiRequest<ApiKeyStatus>(`/user/api-keys/${provider}`, {
|
||||
|
|
|
|||
|
|
@ -1,39 +1,40 @@
|
|||
import { MODELS, type ModelOption } from "../components/assistant/ModelToggle";
|
||||
import type { ApiKeyState } from "@/app/lib/mikeApi";
|
||||
|
||||
export type ModelProvider = "claude" | "gemini";
|
||||
export type ModelProvider = "claude" | "gemini" | "openai";
|
||||
|
||||
export function getModelProvider(modelId: string): ModelProvider | null {
|
||||
const model = MODELS.find((m) => m.id === modelId);
|
||||
if (!model) return null;
|
||||
return model.group === "Anthropic" ? "claude" : "gemini";
|
||||
return modelGroupToProvider(model.group);
|
||||
}
|
||||
|
||||
export function isModelAvailable(
|
||||
modelId: string,
|
||||
apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null },
|
||||
apiKeys: ApiKeyState,
|
||||
): boolean {
|
||||
const provider = getModelProvider(modelId);
|
||||
if (!provider) return false;
|
||||
return provider === "claude"
|
||||
? !!apiKeys.claudeApiKey?.trim()
|
||||
: !!apiKeys.geminiApiKey?.trim();
|
||||
return isProviderAvailable(provider, apiKeys);
|
||||
}
|
||||
|
||||
export function isProviderAvailable(
|
||||
provider: ModelProvider,
|
||||
apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null },
|
||||
apiKeys: ApiKeyState,
|
||||
): boolean {
|
||||
return provider === "claude"
|
||||
? !!apiKeys.claudeApiKey?.trim()
|
||||
: !!apiKeys.geminiApiKey?.trim();
|
||||
return !!apiKeys[provider]?.configured;
|
||||
}
|
||||
|
||||
export function providerLabel(provider: ModelProvider): string {
|
||||
return provider === "claude" ? "Anthropic (Claude)" : "Google (Gemini)";
|
||||
if (provider === "claude") return "Anthropic (Claude)";
|
||||
if (provider === "openai") return "OpenAI";
|
||||
return "Google (Gemini)";
|
||||
}
|
||||
|
||||
export function modelGroupToProvider(
|
||||
group: ModelOption["group"],
|
||||
): ModelProvider {
|
||||
return group === "Anthropic" ? "claude" : "gemini";
|
||||
if (group === "Anthropic") return "claude";
|
||||
if (group === "OpenAI") return "openai";
|
||||
return "gemini";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import React, {
|
|||
} from "react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import {
|
||||
type ApiKeyState,
|
||||
type ApiKeyProvider,
|
||||
type UserProfile as ApiUserProfile,
|
||||
getUserProfile,
|
||||
saveApiKey,
|
||||
|
|
@ -24,8 +26,7 @@ interface UserProfile {
|
|||
creditsRemaining: number;
|
||||
tier: string;
|
||||
tabularModel: string;
|
||||
claudeApiKey: string | null;
|
||||
geminiApiKey: string | null;
|
||||
apiKeys: ApiKeyState;
|
||||
}
|
||||
|
||||
interface UserProfileContextType {
|
||||
|
|
@ -38,7 +39,7 @@ interface UserProfileContextType {
|
|||
value: string,
|
||||
) => Promise<boolean>;
|
||||
updateApiKey: (
|
||||
provider: "claude" | "gemini",
|
||||
provider: ApiKeyProvider,
|
||||
value: string | null,
|
||||
) => Promise<boolean>;
|
||||
reloadProfile: () => Promise<void>;
|
||||
|
|
@ -49,14 +50,31 @@ const UserProfileContext = createContext<UserProfileContextType | undefined>(
|
|||
undefined,
|
||||
);
|
||||
|
||||
const CONFIGURED_KEY_MARKER = "configured";
|
||||
const API_KEY_PROVIDERS: ApiKeyProvider[] = ["claude", "gemini", "openai"];
|
||||
|
||||
function emptyApiKeys(): ApiKeyState {
|
||||
return {
|
||||
claude: { configured: false, source: null },
|
||||
gemini: { configured: false, source: null },
|
||||
openai: { configured: false, source: null },
|
||||
};
|
||||
}
|
||||
|
||||
function toProfile(data: ApiUserProfile): UserProfile {
|
||||
const { apiKeyStatus, ...profile } = data;
|
||||
const apiKeys = emptyApiKeys();
|
||||
for (const provider of API_KEY_PROVIDERS) {
|
||||
apiKeys[provider] = {
|
||||
configured: !!apiKeyStatus[provider],
|
||||
source:
|
||||
apiKeyStatus.sources?.[provider] ??
|
||||
(apiKeyStatus[provider] ? "user" : null),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
claudeApiKey: apiKeyStatus.claude ? CONFIGURED_KEY_MARKER : null,
|
||||
geminiApiKey: apiKeyStatus.gemini ? CONFIGURED_KEY_MARKER : null,
|
||||
apiKeys,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -83,8 +101,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
|
|||
creditsRemaining: 999999, // temporarily unlimited
|
||||
tier: "Free",
|
||||
tabularModel: "gemini-3-flash-preview",
|
||||
claudeApiKey: null,
|
||||
geminiApiKey: null,
|
||||
apiKeys: emptyApiKeys(),
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -157,12 +174,10 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
const updateApiKey = useCallback(
|
||||
async (
|
||||
provider: "claude" | "gemini",
|
||||
provider: ApiKeyProvider,
|
||||
value: string | null,
|
||||
): Promise<boolean> => {
|
||||
if (!user) return false;
|
||||
const stateField =
|
||||
provider === "claude" ? "claudeApiKey" : "geminiApiKey";
|
||||
const normalized = value?.trim() ? value.trim() : null;
|
||||
try {
|
||||
await saveApiKey(provider, normalized);
|
||||
|
|
@ -170,9 +185,13 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
|
|||
prev
|
||||
? {
|
||||
...prev,
|
||||
[stateField]: normalized
|
||||
? CONFIGURED_KEY_MARKER
|
||||
: null,
|
||||
apiKeys: {
|
||||
...prev.apiKeys,
|
||||
[provider]: {
|
||||
configured: !!normalized,
|
||||
source: normalized ? "user" : null,
|
||||
},
|
||||
},
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue