mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-17 18:35:19 +02:00
Merge pull request #831 from AnishSarkar22/fix/ui
feat: multiple UI enhancements
This commit is contained in:
commit
47e6a7f29e
45 changed files with 2176 additions and 2209 deletions
|
|
@ -373,10 +373,11 @@ async def read_documents(
|
|||
# Convert database objects to API-friendly format
|
||||
api_documents = []
|
||||
for doc in db_documents:
|
||||
# Get user name (display_name or email fallback)
|
||||
created_by_name = None
|
||||
created_by_email = None
|
||||
if doc.created_by:
|
||||
created_by_name = doc.created_by.display_name or doc.created_by.email
|
||||
created_by_name = doc.created_by.display_name
|
||||
created_by_email = doc.created_by.email
|
||||
|
||||
# Parse status from JSONB
|
||||
status_data = None
|
||||
|
|
@ -400,6 +401,7 @@ async def read_documents(
|
|||
search_space_id=doc.search_space_id,
|
||||
created_by_id=doc.created_by_id,
|
||||
created_by_name=created_by_name,
|
||||
created_by_email=created_by_email,
|
||||
status=status_data,
|
||||
)
|
||||
)
|
||||
|
|
@ -528,10 +530,11 @@ async def search_documents(
|
|||
# Convert database objects to API-friendly format
|
||||
api_documents = []
|
||||
for doc in db_documents:
|
||||
# Get user name (display_name or email fallback)
|
||||
created_by_name = None
|
||||
created_by_email = None
|
||||
if doc.created_by:
|
||||
created_by_name = doc.created_by.display_name or doc.created_by.email
|
||||
created_by_name = doc.created_by.display_name
|
||||
created_by_email = doc.created_by.email
|
||||
|
||||
# Parse status from JSONB
|
||||
status_data = None
|
||||
|
|
@ -555,6 +558,7 @@ async def search_documents(
|
|||
search_space_id=doc.search_space_id,
|
||||
created_by_id=doc.created_by_id,
|
||||
created_by_name=created_by_name,
|
||||
created_by_email=created_by_email,
|
||||
status=status_data,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -60,9 +60,8 @@ class DocumentRead(BaseModel):
|
|||
updated_at: datetime | None
|
||||
search_space_id: int
|
||||
created_by_id: UUID | None = None # User who created/uploaded this document
|
||||
created_by_name: str | None = (
|
||||
None # Display name or email of the user who created this document
|
||||
)
|
||||
created_by_name: str | None = None
|
||||
created_by_email: str | None = None
|
||||
status: DocumentStatusSchema | None = (
|
||||
None # Processing status (ready, processing, failed)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export function DocumentTypeChip({ type, className }: { type: string; className?
|
|||
|
||||
const chip = (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full bg-accent/80 px-2.5 py-1 text-xs font-medium text-accent-foreground/70 shadow-sm max-w-full overflow-hidden ${className ?? ""}`}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full bg-accent/80 px-2.5 py-1 text-xs font-medium text-accent-foreground shadow-sm max-w-full overflow-hidden ${className ?? ""}`}
|
||||
>
|
||||
<span className="flex-shrink-0">{icon}</span>
|
||||
<span ref={textRef} className="truncate min-w-0">
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@
|
|||
import { useSetAtom } from "jotai";
|
||||
import {
|
||||
CircleAlert,
|
||||
CircleX,
|
||||
FilePlus2,
|
||||
FileType,
|
||||
ListFilter,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
Trash,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
|
@ -81,7 +81,7 @@ export function DocumentsFilters({
|
|||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex flex-col gap-4"
|
||||
className="flex flex-col gap-4 select-none"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.1 }}
|
||||
|
|
@ -96,7 +96,7 @@ export function DocumentsFilters({
|
|||
size="sm"
|
||||
className="h-9 gap-2 bg-white text-gray-700 border-white hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100"
|
||||
>
|
||||
<FilePlus2 size={16} />
|
||||
<Upload size={16} />
|
||||
<span>Upload documents</span>
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -126,7 +126,7 @@ export function DocumentsFilters({
|
|||
<Input
|
||||
id={`${id}-input`}
|
||||
ref={inputRef}
|
||||
className="peer h-9 w-full pl-9 pr-9 text-sm bg-background border-border/60 focus-visible:ring-1 focus-visible:ring-ring/30"
|
||||
className="peer h-9 w-full pl-9 pr-9 text-sm bg-background border-border/60 focus-visible:ring-1 focus-visible:ring-ring/30 select-none focus:select-text"
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
placeholder="Filter by title"
|
||||
|
|
@ -135,7 +135,7 @@ export function DocumentsFilters({
|
|||
/>
|
||||
{Boolean(searchValue) && (
|
||||
<motion.button
|
||||
className="absolute inset-y-0 right-0 flex h-full w-9 items-center justify-center rounded-r-md text-muted-foreground/60 hover:text-foreground transition-colors"
|
||||
className="absolute inset-y-0 right-0 flex h-full w-9 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Clear filter"
|
||||
onClick={() => {
|
||||
onSearch("");
|
||||
|
|
@ -147,7 +147,7 @@ export function DocumentsFilters({
|
|||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<CircleX size={14} strokeWidth={2} aria-hidden="true" />
|
||||
<X size={14} strokeWidth={2} aria-hidden="true" />
|
||||
</motion.button>
|
||||
)}
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -336,7 +336,7 @@ export function DocumentsTableShell({
|
|||
|
||||
return (
|
||||
<motion.div
|
||||
className="rounded-lg border border-border/40 bg-background overflow-hidden"
|
||||
className="rounded-lg border border-border/40 bg-background overflow-hidden select-none"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.2 }}
|
||||
|
|
@ -453,7 +453,7 @@ export function DocumentsTableShell({
|
|||
) : error ? (
|
||||
<div className="flex h-[50vh] w-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<AlertCircle className="h-8 w-8 text-destructive/60" />
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
<p className="text-sm text-destructive">{t("error_loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -482,7 +482,7 @@ export function DocumentsTableShell({
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop Table View - Notion Style */}
|
||||
{/* Desktop Table View */}
|
||||
<div className="hidden md:flex md:flex-col">
|
||||
{/* Fixed Header */}
|
||||
<Table className="table-fixed w-full">
|
||||
|
|
@ -629,7 +629,24 @@ export function DocumentsTableShell({
|
|||
)}
|
||||
{columnVisibility.created_by && (
|
||||
<TableCell className="w-36 py-2.5 text-sm text-foreground truncate border-r border-border/40">
|
||||
{doc.created_by_name || "—"}
|
||||
{doc.created_by_name ? (
|
||||
doc.created_by_email ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default truncate block">
|
||||
{doc.created_by_name}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">
|
||||
{doc.created_by_email}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="truncate block">{doc.created_by_name}</span>
|
||||
)
|
||||
) : (
|
||||
<span className="truncate block">{doc.created_by_email || "—"}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
{columnVisibility.created_at && (
|
||||
|
|
@ -765,11 +782,11 @@ export function DocumentsTableShell({
|
|||
|
||||
{/* Document Content Viewer - lazy loads content on-demand */}
|
||||
<Dialog open={!!viewingDoc} onOpenChange={(open) => !open && handleCloseViewer()}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col overflow-hidden pb-0">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>{viewingDoc?.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-4">
|
||||
<div className="mt-4 overflow-y-auto flex-1 min-h-0 px-6 select-text">
|
||||
{viewingLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner size="lg" className="text-muted-foreground" />
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export function PaginationControls({
|
|||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex items-center justify-end gap-3 py-3 px-2"
|
||||
className="flex items-center justify-end gap-3 py-3 px-2 select-none"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.3 }}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export type Document = {
|
|||
search_space_id: number;
|
||||
created_by_id?: string | null;
|
||||
created_by_name?: string | null;
|
||||
created_by_email?: string | null;
|
||||
status?: DocumentStatus;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ export default function DocumentsTable() {
|
|||
title: item.title,
|
||||
created_by_id: item.created_by_id ?? null,
|
||||
created_by_name: item.created_by_name ?? null,
|
||||
created_by_email: item.created_by_email ?? null,
|
||||
created_at: item.created_at,
|
||||
status: (
|
||||
item as {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ import {
|
|||
ChevronRight,
|
||||
ChevronUp,
|
||||
CircleAlert,
|
||||
CircleX,
|
||||
Clock,
|
||||
Columns3,
|
||||
Filter,
|
||||
|
|
@ -741,7 +740,7 @@ function LogsFilters({
|
|||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<CircleX size={16} strokeWidth={2} />
|
||||
<X size={16} strokeWidth={2} />
|
||||
</Button>
|
||||
)}
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ export default function MorePagesPage() {
|
|||
const allCompleted = data?.tasks.every((t) => t.completed) ?? false;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center px-4 py-8">
|
||||
<div className="flex min-h-[calc(100vh-64px)] select-none items-center justify-center px-4 py-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
|
|
@ -174,7 +174,7 @@ export default function MorePagesPage() {
|
|||
Contact Us
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="select-none sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Contact Us</DialogTitle>
|
||||
<DialogDescription>Schedule a meeting or send us an email.</DialogDescription>
|
||||
|
|
|
|||
|
|
@ -259,7 +259,7 @@ export default function OnboardPage() {
|
|||
You can add more configurations and customize settings anytime in{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/settings`)}
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/settings?section=general`)}
|
||||
className="text-violet-500 hover:underline"
|
||||
>
|
||||
Settings
|
||||
|
|
|
|||
|
|
@ -12,10 +12,11 @@ import {
|
|||
Menu,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
Shield,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { PublicChatSnapshotsManager } from "@/components/public-chat-snapshots/public-chat-snapshots-manager";
|
||||
|
|
@ -24,6 +25,7 @@ import { ImageModelManager } from "@/components/settings/image-model-manager";
|
|||
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
|
||||
import { ModelConfigManager } from "@/components/settings/model-config-manager";
|
||||
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
|
||||
import { RolesManager } from "@/components/settings/roles-manager";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { trackSettingsViewed } from "@/lib/posthog/events";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -72,6 +74,12 @@ const settingsNavItems: SettingsNavItem[] = [
|
|||
descriptionKey: "nav_public_links_desc",
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
id: "team-roles",
|
||||
labelKey: "nav_team_roles",
|
||||
descriptionKey: "nav_team_roles_desc",
|
||||
icon: Shield,
|
||||
},
|
||||
];
|
||||
|
||||
function SettingsSidebar({
|
||||
|
|
@ -240,7 +248,7 @@ function SettingsContent({
|
|||
{/* Section Header */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeSection + "-header"}
|
||||
key={`${activeSection}-header`}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
|
|
@ -298,6 +306,7 @@ function SettingsContent({
|
|||
{activeSection === "public-links" && (
|
||||
<PublicChatSnapshotsManager searchSpaceId={searchSpaceId} />
|
||||
)}
|
||||
{activeSection === "team-roles" && <RolesManager searchSpaceId={searchSpaceId} />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
|
@ -306,14 +315,27 @@ function SettingsContent({
|
|||
);
|
||||
}
|
||||
|
||||
const VALID_SECTIONS = new Set(settingsNavItems.map((item) => item.id));
|
||||
const DEFAULT_SECTION = "general";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const searchSpaceId = Number(params.search_space_id);
|
||||
const [activeSection, setActiveSection] = useState("general");
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
|
||||
// Track settings section view
|
||||
const sectionParam = searchParams.get("section");
|
||||
const activeSection =
|
||||
sectionParam && VALID_SECTIONS.has(sectionParam) ? sectionParam : DEFAULT_SECTION;
|
||||
|
||||
const handleSectionChange = useCallback(
|
||||
(section: string) => {
|
||||
router.replace(`/dashboard/${searchSpaceId}/settings?section=${section}`, { scroll: false });
|
||||
},
|
||||
[router, searchSpaceId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
trackSettingsViewed(searchSpaceId, activeSection);
|
||||
}, [searchSpaceId, activeSection]);
|
||||
|
|
@ -333,7 +355,7 @@ export default function SettingsPage() {
|
|||
<div className="flex h-full w-full overflow-hidden bg-background md:rounded-xl md:border md:shadow-sm">
|
||||
<SettingsSidebar
|
||||
activeSection={activeSection}
|
||||
onSectionChange={setActiveSection}
|
||||
onSectionChange={handleSectionChange}
|
||||
onBackToApp={handleBackToApp}
|
||||
isOpen={isSidebarOpen}
|
||||
onClose={() => setIsSidebarOpen(false)}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -221,7 +221,7 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
|
|||
</TooltipIconButton>
|
||||
)}
|
||||
|
||||
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5">
|
||||
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none">
|
||||
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
|
||||
{/* YouTube Crawler View - shown when adding YouTube videos */}
|
||||
{isYouTubeView && searchSpaceId ? (
|
||||
|
|
@ -374,7 +374,7 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger
|
|||
: "You need to configure a Document Summary LLM before adding connectors. This LLM is used to process and summarize documents from your connected sources."}
|
||||
</p>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href={`/dashboard/${searchSpaceId}/settings`}>
|
||||
<Link href={`/dashboard/${searchSpaceId}/settings?section=models`}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Go to Settings
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ const DocumentUploadPopupContent: FC<{
|
|||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl w-[95vw] sm:w-full h-[calc(100dvh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-3 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5">
|
||||
<DialogContent className="select-none max-w-4xl w-[95vw] sm:w-full h-[calc(100dvh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-3 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5">
|
||||
<DialogTitle className="sr-only">Upload Document</DialogTitle>
|
||||
|
||||
{/* Scrollable container for mobile */}
|
||||
|
|
@ -129,9 +129,6 @@ const DocumentUploadPopupContent: FC<{
|
|||
<div className="sticky top-0 z-20 bg-muted px-4 sm:px-12 pt-4 sm:pt-10 pb-2 sm:pb-0">
|
||||
{/* Upload header */}
|
||||
<div className="flex items-center gap-2 sm:gap-4 mb-2 sm:mb-6">
|
||||
<div className="flex h-9 w-9 sm:h-14 sm:w-14 items-center justify-center rounded-lg sm:rounded-xl bg-primary/10 border border-primary/20 flex-shrink-0">
|
||||
<Upload className="size-4 sm:size-7 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pr-8 sm:pr-0">
|
||||
<h2 className="text-base sm:text-2xl font-semibold tracking-tight">
|
||||
Upload Documents
|
||||
|
|
@ -156,7 +153,7 @@ const DocumentUploadPopupContent: FC<{
|
|||
: "You need to configure a Document Summary LLM before uploading files. This LLM is used to process and summarize your uploaded documents."}
|
||||
</p>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href={`/dashboard/${searchSpaceId}/settings`}>
|
||||
<Link href={`/dashboard/${searchSpaceId}/settings?section=models`}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Go to Settings
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
Dot,
|
||||
DownloadIcon,
|
||||
FileWarning,
|
||||
Paperclip,
|
||||
|
|
@ -745,7 +746,19 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
|||
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipIconButton
|
||||
tooltip={isUploadingDocs ? "Uploading documents..." : "Upload and mention files"}
|
||||
tooltip={
|
||||
isUploadingDocs ? (
|
||||
"Uploading documents..."
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">Upload and mention files</span>
|
||||
<span className="text-xs text-muted-foreground flex items-center">
|
||||
Max 10 files <Dot className="size-3" /> 50 MB each
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Total upload limit: 200 MB</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
side="bottom"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { Slottable } from "@radix-ui/react-slot";
|
||||
import { type ComponentPropsWithRef, forwardRef } from "react";
|
||||
import { type ComponentPropsWithRef, type ReactNode, forwardRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TooltipIconButtonProps = ComponentPropsWithRef<typeof Button> & {
|
||||
tooltip: string;
|
||||
tooltip: ReactNode;
|
||||
side?: "top" | "bottom" | "left" | "right";
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ export function DashboardBreadcrumb() {
|
|||
}
|
||||
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<Breadcrumb className="select-none">
|
||||
<BreadcrumbList>
|
||||
{breadcrumbs.map((item, index) => (
|
||||
<React.Fragment key={`${index}-${item.href || item.label}`}>
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M4.94 4.96a9.97 9.97 0 0 1 10.835-2.182a8.7 8.7 0 0 1 2.033 1.11l-3.006 1.39C12.003 4.101 8.797 4.9 6.84 6.86c-2.564 2.565-3.146 6.954-.36 9.922l.278.284L.124 23c1.875-1.973 3.771-4.427 2.636-7.19c-1.52-3.698-.635-8.03 2.18-10.85M23.9.1c-2.264 3.174-3.184 5.389-2.197 9.64l-.007-.007c.753 3.201-.052 6.75-2.653 9.355c-3.279 3.285-8.526 4.016-12.847 1.06L9.21 18.75c2.758 1.084 5.775.607 7.943-1.564c2.169-2.17 2.655-5.332 1.566-7.963c-.207-.5-.828-.625-1.263-.304L8.59 15.472l12.7-12.77v.01z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M6.469 8.776L16.512 23h-4.464L2.005 8.776H6.47zm-.004 7.9l2.233 3.164L6.467 23H2l4.465-6.324zM22 2.582V23h-3.659V7.764L22 2.582zM22 1l-9.952 14.095-2.233-3.163L17.533 1H22z"/></svg>
|
||||
|
Before Width: | Height: | Size: 589 B After Width: | Height: | Size: 270 B |
|
|
@ -334,7 +334,7 @@ export function LayoutDataProvider({
|
|||
|
||||
const handleSearchSpaceSettings = useCallback(
|
||||
(space: SearchSpace) => {
|
||||
router.push(`/dashboard/${space.id}/settings`);
|
||||
router.push(`/dashboard/${space.id}/settings?section=general`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
|
@ -478,7 +478,7 @@ export function LayoutDataProvider({
|
|||
);
|
||||
|
||||
const handleSettings = useCallback(() => {
|
||||
router.push(`/dashboard/${searchSpaceId}/settings`);
|
||||
router.push(`/dashboard/${searchSpaceId}/settings?section=general`);
|
||||
}, [router, searchSpaceId]);
|
||||
|
||||
const handleManageMembers = useCallback(() => {
|
||||
|
|
@ -703,7 +703,6 @@ export function LayoutDataProvider({
|
|||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<PencilIcon className="h-5 w-5" />
|
||||
<span>{tSidebar("rename_chat") || "Rename Chat"}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
@ -736,7 +735,7 @@ export function LayoutDataProvider({
|
|||
{isRenamingChat ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
{tSidebar("renaming") || "Renaming..."}
|
||||
{tSidebar("renaming") || "Renaming"}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
|
@ -86,9 +85,6 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
|
|||
<DialogContent className="max-w-[90vw] sm:max-w-sm p-4 sm:p-5 data-[state=open]:animate-none data-[state=closed]:animate-none">
|
||||
<DialogHeader className="space-y-2 pb-2">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
|
||||
<Search className="h-4 w-4 sm:h-5 sm:w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<DialogTitle className="text-base sm:text-lg">{t("create_title")}</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm mt-0.5">
|
||||
|
|
@ -142,20 +138,20 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
|
|||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2 pt-2 sm:pt-3">
|
||||
<DialogFooter className="flex-row gap-2 pt-2 sm:pt-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto h-9 sm:h-10 text-sm"
|
||||
className="flex-1 sm:flex-none sm:w-auto h-8 sm:h-10 text-xs sm:text-sm"
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto h-9 sm:h-10 text-sm"
|
||||
className="flex-1 sm:flex-none sm:w-auto h-8 sm:h-10 text-xs sm:text-sm"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
|
|
@ -163,10 +159,7 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
|
|||
{t("creating")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="-mr-1 h-4 w-4" />
|
||||
{t("create_button")}
|
||||
</>
|
||||
<>{t("create_button")}</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -210,6 +210,26 @@ export function LayoutShell({
|
|||
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile All Shared Chats - slide-out panel */}
|
||||
{allSharedChatsPanel && (
|
||||
<AllSharedChatsSidebar
|
||||
open={allSharedChatsPanel.open}
|
||||
onOpenChange={allSharedChatsPanel.onOpenChange}
|
||||
searchSpaceId={allSharedChatsPanel.searchSpaceId}
|
||||
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile All Private Chats - slide-out panel */}
|
||||
{allPrivateChatsPanel && (
|
||||
<AllPrivateChatsSidebar
|
||||
open={allPrivateChatsPanel.open}
|
||||
onOpenChange={allPrivateChatsPanel.onOpenChange}
|
||||
searchSpaceId={allPrivateChatsPanel.searchSpaceId}
|
||||
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarProvider>
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
import { format } from "date-fns";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ChevronLeft,
|
||||
MessageCircleMore,
|
||||
MoreHorizontal,
|
||||
PenLine,
|
||||
RotateCcwIcon,
|
||||
Search,
|
||||
Trash2,
|
||||
|
|
@ -17,6 +19,14 @@ import { useTranslations } from "next-intl";
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -69,6 +79,10 @@ export function AllPrivateChatsSidebar({
|
|||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [openDropdownId, setOpenDropdownId] = useState<number | null>(null);
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false);
|
||||
const [renamingThread, setRenamingThread] = useState<{ id: number; title: string } | null>(null);
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
||||
|
||||
const isSearchMode = !!debouncedSearchQuery.trim();
|
||||
|
|
@ -187,6 +201,35 @@ export function AllPrivateChatsSidebar({
|
|||
[queryClient, searchSpaceId, t]
|
||||
);
|
||||
|
||||
const handleStartRename = useCallback((threadId: number, title: string) => {
|
||||
setRenamingThread({ id: threadId, title });
|
||||
setNewTitle(title);
|
||||
setShowRenameDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmRename = useCallback(async () => {
|
||||
if (!renamingThread || !newTitle.trim()) return;
|
||||
setIsRenaming(true);
|
||||
try {
|
||||
await updateThread(renamingThread.id, { title: newTitle.trim() });
|
||||
toast.success(t("chat_renamed") || "Chat renamed");
|
||||
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["threads", searchSpaceId, "detail", String(renamingThread.id)],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error renaming thread:", error);
|
||||
toast.error(t("error_renaming_chat") || "Failed to rename chat");
|
||||
} finally {
|
||||
setIsRenaming(false);
|
||||
setShowRenameDialog(false);
|
||||
setRenamingThread(null);
|
||||
setNewTitle("");
|
||||
}
|
||||
}, [renamingThread, newTitle, queryClient, searchSpaceId, t]);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchQuery("");
|
||||
}, []);
|
||||
|
|
@ -205,6 +248,17 @@ export function AllPrivateChatsSidebar({
|
|||
>
|
||||
<div className="shrink-0 p-4 pb-2 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{isMobile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="sr-only">{t("close") || "Close"}</span>
|
||||
</Button>
|
||||
)}
|
||||
<User className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
|
||||
</div>
|
||||
|
|
@ -356,6 +410,14 @@ export function AllPrivateChatsSidebar({
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40 z-80">
|
||||
{!thread.archived && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStartRename(thread.id, thread.title || "New Chat")}
|
||||
>
|
||||
<PenLine className="mr-2 h-4 w-4" />
|
||||
<span>{t("rename") || "Rename"}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleToggleArchive(thread.id, thread.archived)}
|
||||
disabled={isArchiving}
|
||||
|
|
@ -412,6 +474,51 @@ export function AllPrivateChatsSidebar({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<span>{t("rename_chat") || "Rename Chat"}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("rename_chat_description") || "Enter a new name for this conversation."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
placeholder={t("chat_title_placeholder") || "Chat title"}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !isRenaming && newTitle.trim()) {
|
||||
handleConfirmRename();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowRenameDialog(false)}
|
||||
disabled={isRenaming}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmRename}
|
||||
disabled={isRenaming || !newTitle.trim()}
|
||||
className="gap-2"
|
||||
>
|
||||
{isRenaming ? (
|
||||
<>
|
||||
<Spinner size="xs" />
|
||||
<span>{t("renaming") || "Renaming"}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{t("rename") || "Rename"}</span>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SidebarSlideOutPanel>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
import { format } from "date-fns";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ChevronLeft,
|
||||
MessageCircleMore,
|
||||
MoreHorizontal,
|
||||
PenLine,
|
||||
RotateCcwIcon,
|
||||
Search,
|
||||
Trash2,
|
||||
|
|
@ -17,6 +19,14 @@ import { useTranslations } from "next-intl";
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -69,6 +79,10 @@ export function AllSharedChatsSidebar({
|
|||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [openDropdownId, setOpenDropdownId] = useState<number | null>(null);
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false);
|
||||
const [renamingThread, setRenamingThread] = useState<{ id: number; title: string } | null>(null);
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
||||
|
||||
const isSearchMode = !!debouncedSearchQuery.trim();
|
||||
|
|
@ -187,6 +201,35 @@ export function AllSharedChatsSidebar({
|
|||
[queryClient, searchSpaceId, t]
|
||||
);
|
||||
|
||||
const handleStartRename = useCallback((threadId: number, title: string) => {
|
||||
setRenamingThread({ id: threadId, title });
|
||||
setNewTitle(title);
|
||||
setShowRenameDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmRename = useCallback(async () => {
|
||||
if (!renamingThread || !newTitle.trim()) return;
|
||||
setIsRenaming(true);
|
||||
try {
|
||||
await updateThread(renamingThread.id, { title: newTitle.trim() });
|
||||
toast.success(t("chat_renamed") || "Chat renamed");
|
||||
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["threads", searchSpaceId, "detail", String(renamingThread.id)],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error renaming thread:", error);
|
||||
toast.error(t("error_renaming_chat") || "Failed to rename chat");
|
||||
} finally {
|
||||
setIsRenaming(false);
|
||||
setShowRenameDialog(false);
|
||||
setRenamingThread(null);
|
||||
setNewTitle("");
|
||||
}
|
||||
}, [renamingThread, newTitle, queryClient, searchSpaceId, t]);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchQuery("");
|
||||
}, []);
|
||||
|
|
@ -205,6 +248,17 @@ export function AllSharedChatsSidebar({
|
|||
>
|
||||
<div className="shrink-0 p-4 pb-2 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{isMobile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="sr-only">{t("close") || "Close"}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("shared_chats") || "Shared Chats"}</h2>
|
||||
</div>
|
||||
|
|
@ -356,6 +410,14 @@ export function AllSharedChatsSidebar({
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40 z-80">
|
||||
{!thread.archived && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStartRename(thread.id, thread.title || "New Chat")}
|
||||
>
|
||||
<PenLine className="mr-2 h-4 w-4" />
|
||||
<span>{t("rename") || "Rename"}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleToggleArchive(thread.id, thread.archived)}
|
||||
disabled={isArchiving}
|
||||
|
|
@ -412,6 +474,51 @@ export function AllSharedChatsSidebar({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<span>{t("rename_chat") || "Rename Chat"}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("rename_chat_description") || "Enter a new name for this conversation."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
placeholder={t("chat_title_placeholder") || "Chat title"}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !isRenaming && newTitle.trim()) {
|
||||
handleConfirmRename();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowRenameDialog(false)}
|
||||
disabled={isRenaming}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmRename}
|
||||
disabled={isRenaming || !newTitle.trim()}
|
||||
className="gap-2"
|
||||
>
|
||||
{isRenaming ? (
|
||||
<>
|
||||
<Spinner size="xs" />
|
||||
<span>{t("renaming") || "Renaming"}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{t("rename") || "Rename"}</span>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SidebarSlideOutPanel>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import {
|
|||
ArchiveIcon,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
PencilIcon,
|
||||
PenLine,
|
||||
RotateCcwIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
|
@ -74,7 +74,7 @@ export function ChatListItem({
|
|||
onRename();
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="mr-2 h-4 w-4" />
|
||||
<PenLine className="mr-2 h-4 w-4" />
|
||||
<span>{t("rename") || "Rename"}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -727,7 +727,7 @@ export function InboxSidebar({
|
|||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className={cn("z-80", activeTab === "status" ? "w-52" : "w-44")}
|
||||
className={cn("z-80 select-none", activeTab === "status" ? "w-52" : "w-44")}
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
|
||||
{t("filter") || "Filter"}
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ export function MobileSidebar({
|
|||
</div>
|
||||
|
||||
{/* Sidebar Content - right side */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-hidden flex flex-col [&>*]:!w-full">
|
||||
<Sidebar
|
||||
searchSpace={searchSpace}
|
||||
isCollapsed={false}
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ export function Sidebar({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex h-full flex-col bg-sidebar text-sidebar-foreground overflow-hidden",
|
||||
"relative flex h-full flex-col bg-sidebar text-sidebar-foreground overflow-hidden select-none",
|
||||
isCollapsed ? "w-[60px] transition-all duration-200" : "",
|
||||
!isCollapsed && !isResizing ? "transition-all duration-200" : "",
|
||||
className
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronsUpDown, Settings, Users } from "lucide-react";
|
||||
import { ChevronsUpDown, Settings, UserPen } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -51,14 +51,14 @@ export function SidebarHeader({
|
|||
<ChevronsUpDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
<DropdownMenuItem onClick={onManageMembers}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
<UserPen className="h-4 w-4" />
|
||||
{t("manage_members")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onSettings}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<Settings className="h-4 w-4" />
|
||||
{t("search_space_settings")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export function SidebarSlideOutPanel({
|
|||
exit={{ x: "-100%" }}
|
||||
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
className={cn(
|
||||
"h-full w-full bg-background flex flex-col pointer-events-auto",
|
||||
"h-full w-full bg-background flex flex-col pointer-events-auto select-none",
|
||||
"sm:border-r sm:shadow-xl"
|
||||
)}
|
||||
role="dialog"
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ export function SidebarUserProfile({
|
|||
<TooltipContent side="right">{displayName}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenuContent className="w-56" side="right" align="center" sideOffset={8}>
|
||||
<DropdownMenuContent className="w-48" side="right" align="center" sideOffset={8}>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
|
||||
|
|
@ -191,14 +191,14 @@ export function SidebarUserProfile({
|
|||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={onUserSettings}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<Settings className="h-4 w-4" />
|
||||
{t("user_settings")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{setTheme && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
<Sun className="h-4 w-4" />
|
||||
{t("theme")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
|
|
@ -216,7 +216,7 @@ export function SidebarUserProfile({
|
|||
isSelected && "text-primary"
|
||||
)}
|
||||
>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="flex-1">{t(themeOption.value)}</span>
|
||||
{isSelected && <Check className="h-4 w-4 shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -229,7 +229,7 @@ export function SidebarUserProfile({
|
|||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Languages className="mr-2 h-4 w-4" />
|
||||
<Languages className="h-4 w-4" />
|
||||
{t("language")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
|
|
@ -262,7 +262,7 @@ export function SidebarUserProfile({
|
|||
{isLoggingOut ? (
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
) : (
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<LogOut className="h-4 w-4" />
|
||||
)}
|
||||
{isLoggingOut ? t("loggingOut") : t("logout")}
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -299,7 +299,7 @@ export function SidebarUserProfile({
|
|||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-56" side="top" align="center" sideOffset={4}>
|
||||
<DropdownMenuContent className="w-48" side="top" align="center" sideOffset={4}>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
|
||||
|
|
@ -313,14 +313,14 @@ export function SidebarUserProfile({
|
|||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={onUserSettings}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<Settings className="h-4 w-4" />
|
||||
{t("user_settings")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{setTheme && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
<Sun className="h-4 w-4" />
|
||||
{t("theme")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
|
|
@ -338,7 +338,7 @@ export function SidebarUserProfile({
|
|||
isSelected && "text-primary"
|
||||
)}
|
||||
>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="flex-1">{t(themeOption.value)}</span>
|
||||
{isSelected && <Check className="h-4 w-4 shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -351,7 +351,7 @@ export function SidebarUserProfile({
|
|||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Languages className="mr-2 h-4 w-4" />
|
||||
<Languages className="h-4 w-4" />
|
||||
{t("language")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
|
|
@ -381,11 +381,7 @@ export function SidebarUserProfile({
|
|||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
||||
{isLoggingOut ? (
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
) : (
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isLoggingOut ? <Spinner size="sm" className="mr-2" /> : <LogOut className="h-4 w-4" />}
|
||||
{isLoggingOut ? t("loggingOut") : t("logout")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
|
|
|||
|
|
@ -144,7 +144,9 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/dashboard/${params.search_space_id}/settings`)}
|
||||
onClick={() =>
|
||||
router.push(`/dashboard/${params.search_space_id}/settings?section=public-links`)
|
||||
}
|
||||
className="flex items-center justify-center h-8 w-8 rounded-md bg-muted/50 hover:bg-muted transition-colors"
|
||||
>
|
||||
<Earth className="h-4 w-4 text-muted-foreground" />
|
||||
|
|
@ -181,7 +183,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
sideOffset={8}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="p-1.5 space-y-1">
|
||||
<div className="p-1.5 space-y-1 select-none">
|
||||
{/* Visibility Options */}
|
||||
{visibilityOptions.map((option) => {
|
||||
const isSelected = currentVisibility === option.value;
|
||||
|
|
|
|||
|
|
@ -226,7 +226,7 @@ export function ModelSelector({
|
|||
size="sm"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn("h-8 gap-2 px-3 text-sm border-border/60", className)}
|
||||
className={cn("h-8 gap-2 px-3 text-sm border-border/60 select-none", className)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
|
|
@ -280,7 +280,7 @@ export function ModelSelector({
|
|||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
className="w-[280px] md:w-[360px] p-0 rounded-lg shadow-lg border-border/60"
|
||||
className="w-[280px] md:w-[360px] p-0 rounded-lg shadow-lg border-border/60 dark:bg-muted dark:border dark:border-neutral-700 select-none"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
|
|
@ -289,18 +289,18 @@ export function ModelSelector({
|
|||
onValueChange={(v) => setActiveTab(v as "llm" | "image")}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="border-b border-border/40">
|
||||
<TabsList className="w-full grid grid-cols-2 rounded-none rounded-t-lg bg-card h-11 p-0 gap-0">
|
||||
<div className="border-b border-border/80 dark:border-white/5">
|
||||
<TabsList className="w-full grid grid-cols-2 rounded-none rounded-t-lg bg-transparent h-11 p-0 gap-0">
|
||||
<TabsTrigger
|
||||
value="llm"
|
||||
className="relative gap-2 text-sm font-medium rounded-none text-muted-foreground/60 transition-all duration-200 h-full data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none data-[state=active]:after:absolute data-[state=active]:after:bottom-0 data-[state=active]:after:left-3 data-[state=active]:after:right-3 data-[state=active]:after:h-[2px] data-[state=active]:after:bg-white data-[state=active]:after:rounded-full"
|
||||
className="gap-2 text-sm font-medium rounded-none text-muted-foreground/60 transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
|
||||
>
|
||||
<Zap className="size-4" />
|
||||
LLM
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="image"
|
||||
className="relative gap-2 text-sm font-medium rounded-none text-muted-foreground/60 transition-all duration-200 h-full data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none data-[state=active]:after:absolute data-[state=active]:after:bottom-0 data-[state=active]:after:left-3 data-[state=active]:after:right-3 data-[state=active]:after:h-[2px] data-[state=active]:after:bg-white data-[state=active]:after:rounded-full"
|
||||
className="gap-2 text-sm font-medium rounded-none text-muted-foreground/60 transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
|
||||
>
|
||||
<ImageIcon className="size-4" />
|
||||
Image
|
||||
|
|
@ -312,7 +312,7 @@ export function ModelSelector({
|
|||
<TabsContent value="llm" className="mt-0">
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
className="rounded-none rounded-b-lg relative [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||
className="rounded-none rounded-b-lg relative dark:bg-muted [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||
>
|
||||
{totalLLMModels > 3 && (
|
||||
<div className="px-2 md:px-3 py-1.5 md:py-2">
|
||||
|
|
@ -350,9 +350,9 @@ export function ModelSelector({
|
|||
onSelect={() => handleSelectLLM(config)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
||||
"hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80",
|
||||
isAutoMode && "border border-violet-800"
|
||||
"hover:bg-accent/50 dark:hover:bg-white/10",
|
||||
isSelected && "bg-accent/80 dark:bg-white/10",
|
||||
isAutoMode && ""
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
|
|
@ -366,7 +366,7 @@ export function ModelSelector({
|
|||
{isAutoMode && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300 border-0"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-violet-800 text-white dark:bg-violet-800 dark:text-white border-0"
|
||||
>
|
||||
Recommended
|
||||
</Badge>
|
||||
|
|
@ -426,8 +426,8 @@ export function ModelSelector({
|
|||
onSelect={() => handleSelectLLM(config)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
||||
"hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80"
|
||||
"hover:bg-accent/50 dark:hover:bg-white/10",
|
||||
isSelected && "bg-accent/80 dark:bg-white/10"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
|
|
@ -471,11 +471,11 @@ export function ModelSelector({
|
|||
)}
|
||||
|
||||
{/* Add New LLM Config */}
|
||||
<div className="p-2 bg-muted/20">
|
||||
<div className="p-2 bg-muted/20 dark:bg-muted">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50"
|
||||
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/10"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onAddNewLLM();
|
||||
|
|
@ -493,7 +493,7 @@ export function ModelSelector({
|
|||
<TabsContent value="image" className="mt-0">
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
className="rounded-none rounded-b-lg [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||
className="rounded-none rounded-b-lg dark:bg-muted [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||
>
|
||||
{totalImageModels > 3 && (
|
||||
<div className="px-2 md:px-3 py-1.5 md:py-2">
|
||||
|
|
@ -528,9 +528,9 @@ export function ModelSelector({
|
|||
value={`img-g-${config.id}`}
|
||||
onSelect={() => handleSelectImage(config.id)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80",
|
||||
isAuto && "border border-violet-800"
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/10",
|
||||
isSelected && "bg-accent/80 dark:bg-white/10",
|
||||
isAuto && ""
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
|
|
@ -543,7 +543,7 @@ export function ModelSelector({
|
|||
{isAuto && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300 border-0"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-violet-800 text-white dark:bg-violet-800 dark:text-white border-0"
|
||||
>
|
||||
Recommended
|
||||
</Badge>
|
||||
|
|
@ -593,8 +593,8 @@ export function ModelSelector({
|
|||
value={`img-u-${config.id}`}
|
||||
onSelect={() => handleSelectImage(config.id)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80"
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/10",
|
||||
isSelected && "bg-accent/80 dark:bg-white/10"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
|
|
@ -634,11 +634,11 @@ export function ModelSelector({
|
|||
|
||||
{/* Add New Image Config */}
|
||||
{onAddNewImage && (
|
||||
<div className="p-2 bg-muted/20">
|
||||
<div className="p-2 bg-muted/20 dark:bg-muted">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50"
|
||||
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/10"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onAddNewImage();
|
||||
|
|
|
|||
|
|
@ -35,12 +35,12 @@ export function PublicChatSnapshotRow({
|
|||
memberMap,
|
||||
}: PublicChatSnapshotRowProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
const handleCopyClick = useCallback(() => {
|
||||
onCopy(snapshot);
|
||||
setCopied(true);
|
||||
clearTimeout(copyTimeoutRef.current);
|
||||
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
|
||||
copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
}, [onCopy, snapshot]);
|
||||
|
||||
|
|
@ -117,12 +117,14 @@ export function PublicChatSnapshotRow({
|
|||
|
||||
{/* Public URL – selectable fallback for manual copy */}
|
||||
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
|
||||
<p
|
||||
className="min-w-0 flex-1 text-[10px] font-mono text-muted-foreground break-all select-all cursor-text"
|
||||
title={snapshot.public_url}
|
||||
>
|
||||
{snapshot.public_url}
|
||||
</p>
|
||||
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
|
||||
<p
|
||||
className="text-[10px] font-mono text-muted-foreground whitespace-nowrap select-all cursor-text"
|
||||
title={snapshot.public_url}
|
||||
>
|
||||
{snapshot.public_url}
|
||||
</p>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -334,7 +334,7 @@ function ReportPanelContent({
|
|||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={`min-w-[180px] dark:bg-neutral-800 dark:border dark:border-neutral-700${insideDrawer ? " z-[100]" : ""}`}
|
||||
className={`min-w-[180px] bg-muted dark:border dark:border-neutral-700${insideDrawer ? " z-[100]" : ""}`}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => handleExport("md")}>
|
||||
Download Markdown
|
||||
|
|
@ -361,56 +361,30 @@ function ReportPanelContent({
|
|||
</DropdownMenu>
|
||||
|
||||
{/* Version switcher — only shown when multiple versions exist */}
|
||||
{versions.length > 1 &&
|
||||
(insideDrawer ? (
|
||||
/* Mobile: compact dropdown */
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3.5 py-4 text-[15px] gap-1.5"
|
||||
{versions.length > 1 && (
|
||||
<DropdownMenu modal={insideDrawer ? false : undefined}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 px-3.5 py-4 text-[15px] gap-1.5">
|
||||
v{activeVersionIndex + 1}
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={`min-w-[120px] bg-muted dark:border dark:border-neutral-700${insideDrawer ? " z-[100]" : ""}`}
|
||||
>
|
||||
{versions.map((v, i) => (
|
||||
<DropdownMenuItem
|
||||
key={v.id}
|
||||
onClick={() => setActiveReportId(v.id)}
|
||||
className={v.id === activeReportId ? "bg-accent font-medium" : ""}
|
||||
>
|
||||
v{activeVersionIndex + 1}
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-[120px] z-[100]">
|
||||
{versions.map((v, i) => (
|
||||
<DropdownMenuItem
|
||||
key={v.id}
|
||||
onClick={() => setActiveReportId(v.id)}
|
||||
className={v.id === activeReportId ? "bg-accent font-medium" : ""}
|
||||
>
|
||||
Version {i + 1}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
/* Desktop: inline version buttons */
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-0.5 rounded-lg border bg-muted/30 p-0.5">
|
||||
{versions.map((v, i) => (
|
||||
<button
|
||||
key={v.id}
|
||||
type="button"
|
||||
onClick={() => setActiveReportId(v.id)}
|
||||
className={`px-2 py-0.5 rounded-md text-xs font-medium transition-colors ${
|
||||
v.id === activeReportId
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
v{i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums ml-1">
|
||||
{activeVersionIndex + 1} of {versions.length}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
Version {i + 1}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
{onClose && (
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
|
||||
|
|
|
|||
1132
surfsense_web/components/settings/roles-manager.tsx
Normal file
1132
surfsense_web/components/settings/roles-manager.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { CheckCircle2, FileType, Info, Tag, Upload, X } from "lucide-react";
|
||||
import { CheckCircle2, FileType, Info, Upload, X } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
|
|
@ -452,7 +452,6 @@ export function DocumentUploadTab({
|
|||
<AccordionItem value="supported-file-types" className="border-0">
|
||||
<AccordionTrigger className="px-3 sm:px-6 py-3 sm:py-4 hover:no-underline !items-center [&>svg]:!translate-y-0">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Tag className="h-4 w-4 sm:h-5 sm:w-5 shrink-0" />
|
||||
<div className="text-left min-w-0">
|
||||
<div className="font-semibold text-sm sm:text-base">
|
||||
{t("supported_file_types")}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export const document = z.object({
|
|||
search_space_id: z.number(),
|
||||
created_by_id: z.string().nullable().optional(),
|
||||
created_by_name: z.string().nullable().optional(),
|
||||
created_by_email: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const extensionDocumentContent = z.object({
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ interface DocumentElectric {
|
|||
status: DocumentStatusType | null;
|
||||
}
|
||||
|
||||
// Document for display (with resolved user name)
|
||||
// Document for display (with resolved user name and email)
|
||||
export interface DocumentDisplay {
|
||||
id: number;
|
||||
search_space_id: number;
|
||||
|
|
@ -34,6 +34,7 @@ export interface DocumentDisplay {
|
|||
title: string;
|
||||
created_by_id: string | null;
|
||||
created_by_name: string | null;
|
||||
created_by_email: string | null;
|
||||
created_at: string;
|
||||
status: DocumentStatusType;
|
||||
}
|
||||
|
|
@ -94,8 +95,9 @@ export function useDocuments(
|
|||
// Track if initial API load is complete (source of truth)
|
||||
const apiLoadedRef = useRef(false);
|
||||
|
||||
// User cache: userId → displayName
|
||||
// User cache: userId → displayName / email
|
||||
const userCacheRef = useRef<Map<string, string>>(new Map());
|
||||
const emailCacheRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// Electric sync refs
|
||||
const syncHandleRef = useRef<SyncHandle | null>(null);
|
||||
|
|
@ -119,10 +121,21 @@ export function useDocuments(
|
|||
|
||||
// Populate user cache from API response
|
||||
const populateUserCache = useCallback(
|
||||
(items: Array<{ created_by_id?: string | null; created_by_name?: string | null }>) => {
|
||||
(
|
||||
items: Array<{
|
||||
created_by_id?: string | null;
|
||||
created_by_name?: string | null;
|
||||
created_by_email?: string | null;
|
||||
}>
|
||||
) => {
|
||||
for (const item of items) {
|
||||
if (item.created_by_id && item.created_by_name) {
|
||||
userCacheRef.current.set(item.created_by_id, item.created_by_name);
|
||||
if (item.created_by_id) {
|
||||
if (item.created_by_name) {
|
||||
userCacheRef.current.set(item.created_by_id, item.created_by_name);
|
||||
}
|
||||
if (item.created_by_email) {
|
||||
emailCacheRef.current.set(item.created_by_id, item.created_by_email);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -138,6 +151,7 @@ export function useDocuments(
|
|||
title: string;
|
||||
created_by_id?: string | null;
|
||||
created_by_name?: string | null;
|
||||
created_by_email?: string | null;
|
||||
created_at: string;
|
||||
status?: DocumentStatusType | null;
|
||||
}): DocumentDisplay => ({
|
||||
|
|
@ -147,6 +161,7 @@ export function useDocuments(
|
|||
title: item.title,
|
||||
created_by_id: item.created_by_id ?? null,
|
||||
created_by_name: item.created_by_name ?? null,
|
||||
created_by_email: item.created_by_email ?? null,
|
||||
created_at: item.created_at,
|
||||
status: item.status ?? { state: "ready" },
|
||||
}),
|
||||
|
|
@ -160,6 +175,9 @@ export function useDocuments(
|
|||
created_by_name: doc.created_by_id
|
||||
? (userCacheRef.current.get(doc.created_by_id) ?? null)
|
||||
: null,
|
||||
created_by_email: doc.created_by_id
|
||||
? (emailCacheRef.current.get(doc.created_by_id) ?? null)
|
||||
: null,
|
||||
status: doc.status ?? { state: "ready" },
|
||||
}),
|
||||
[]
|
||||
|
|
@ -351,6 +369,9 @@ export function useDocuments(
|
|||
created_by_name: doc.created_by_id
|
||||
? (userCacheRef.current.get(doc.created_by_id) ?? null)
|
||||
: null,
|
||||
created_by_email: doc.created_by_id
|
||||
? (emailCacheRef.current.get(doc.created_by_id) ?? null)
|
||||
: null,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
|
@ -455,6 +476,7 @@ export function useDocuments(
|
|||
setAllDocuments([]);
|
||||
apiLoadedRef.current = false;
|
||||
userCacheRef.current.clear();
|
||||
emailCacheRef.current.clear();
|
||||
}
|
||||
prevSearchSpaceIdRef.current = searchSpaceId;
|
||||
}, [searchSpaceId]);
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export function getProviderIcon(
|
|||
{ isAutoMode, className = "size-4" }: { isAutoMode?: boolean; className?: string } = {}
|
||||
) {
|
||||
if (isAutoMode || provider?.toUpperCase() === "AUTO") {
|
||||
return <Shuffle className={cn(className, "text-violet-800")} />;
|
||||
return <Shuffle className={cn(className, "text-muted-foreground")} />;
|
||||
}
|
||||
|
||||
switch (provider?.toUpperCase()) {
|
||||
|
|
|
|||
|
|
@ -682,7 +682,7 @@
|
|||
"rename_chat": "Rename Chat",
|
||||
"rename_chat_description": "Enter a new name for this conversation.",
|
||||
"chat_title_placeholder": "Chat title",
|
||||
"renaming": "Renaming...",
|
||||
"renaming": "Renaming",
|
||||
"no_archived_chats": "No archived chats",
|
||||
"error_archiving_chat": "Failed to archive chat",
|
||||
"new_chat": "New chat",
|
||||
|
|
@ -720,7 +720,8 @@
|
|||
"unread": "Unread",
|
||||
"connectors": "Connectors",
|
||||
"all_connectors": "All connectors",
|
||||
"close": "Close"
|
||||
"close": "Close",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"errors": {
|
||||
"something_went_wrong": "Something went wrong",
|
||||
|
|
@ -746,6 +747,8 @@
|
|||
"nav_system_instructions_desc": "SearchSpace-wide AI instructions",
|
||||
"nav_public_links": "Public Chat Links",
|
||||
"nav_public_links_desc": "Manage publicly shared chat links",
|
||||
"nav_team_roles": "Team Roles",
|
||||
"nav_team_roles_desc": "Manage team roles & permissions",
|
||||
"general_name_label": "Name",
|
||||
"general_name_placeholder": "Enter search space name",
|
||||
"general_name_description": "A unique name for your search space.",
|
||||
|
|
|
|||
|
|
@ -720,7 +720,8 @@
|
|||
"unread": "No leído",
|
||||
"connectors": "Conectores",
|
||||
"all_connectors": "Todos los conectores",
|
||||
"close": "Cerrar"
|
||||
"close": "Cerrar",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"errors": {
|
||||
"something_went_wrong": "Algo salió mal",
|
||||
|
|
@ -746,6 +747,8 @@
|
|||
"nav_system_instructions_desc": "Instrucciones de IA a nivel del espacio de búsqueda",
|
||||
"nav_public_links": "Enlaces de chat públicos",
|
||||
"nav_public_links_desc": "Administrar enlaces de chat compartidos públicamente",
|
||||
"nav_team_roles": "Team Roles",
|
||||
"nav_team_roles_desc": "Manage team roles & permissions",
|
||||
"general_name_label": "Nombre",
|
||||
"general_name_placeholder": "Ingresa el nombre del espacio de búsqueda",
|
||||
"general_name_description": "Un nombre único para tu espacio de búsqueda.",
|
||||
|
|
|
|||
|
|
@ -720,7 +720,8 @@
|
|||
"unread": "अपठित",
|
||||
"connectors": "कनेक्टर",
|
||||
"all_connectors": "सभी कनेक्टर",
|
||||
"close": "बंद करें"
|
||||
"close": "बंद करें",
|
||||
"cancel": "रद्द करें"
|
||||
},
|
||||
"errors": {
|
||||
"something_went_wrong": "कुछ गलत हो गया",
|
||||
|
|
@ -746,6 +747,8 @@
|
|||
"nav_system_instructions_desc": "सर्च स्पेस-व्यापी AI निर्देश",
|
||||
"nav_public_links": "सार्वजनिक चैट लिंक",
|
||||
"nav_public_links_desc": "सार्वजनिक रूप से साझा किए गए चैट लिंक प्रबंधित करें",
|
||||
"nav_team_roles": "Team Roles",
|
||||
"nav_team_roles_desc": "Manage team roles & permissions",
|
||||
"general_name_label": "नाम",
|
||||
"general_name_placeholder": "सर्च स्पेस का नाम दर्ज करें",
|
||||
"general_name_description": "आपके सर्च स्पेस के लिए एक अद्वितीय नाम।",
|
||||
|
|
|
|||
|
|
@ -720,7 +720,8 @@
|
|||
"unread": "Não lido",
|
||||
"connectors": "Conectores",
|
||||
"all_connectors": "Todos os conectores",
|
||||
"close": "Fechar"
|
||||
"close": "Fechar",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"errors": {
|
||||
"something_went_wrong": "Algo deu errado",
|
||||
|
|
@ -746,6 +747,8 @@
|
|||
"nav_system_instructions_desc": "Instruções de IA em nível do espaço de pesquisa",
|
||||
"nav_public_links": "Links de chat públicos",
|
||||
"nav_public_links_desc": "Gerenciar links de chat compartilhados publicamente",
|
||||
"nav_team_roles": "Team Roles",
|
||||
"nav_team_roles_desc": "Manage team roles & permissions",
|
||||
"general_name_label": "Nome",
|
||||
"general_name_placeholder": "Insira o nome do espaço de pesquisa",
|
||||
"general_name_description": "Um nome único para seu espaço de pesquisa.",
|
||||
|
|
|
|||
|
|
@ -704,7 +704,8 @@
|
|||
"unread": "未读",
|
||||
"connectors": "连接器",
|
||||
"all_connectors": "所有连接器",
|
||||
"close": "关闭"
|
||||
"close": "关闭",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"errors": {
|
||||
"something_went_wrong": "出错了",
|
||||
|
|
@ -730,6 +731,8 @@
|
|||
"nav_system_instructions_desc": "搜索空间级别的 AI 指令",
|
||||
"nav_public_links": "公开聊天链接",
|
||||
"nav_public_links_desc": "管理公开分享的聊天链接",
|
||||
"nav_team_roles": "Team Roles",
|
||||
"nav_team_roles_desc": "Manage team roles & permissions",
|
||||
"general_name_label": "名称",
|
||||
"general_name_placeholder": "输入搜索空间名称",
|
||||
"general_name_description": "您的搜索空间的唯一名称。",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue