feat: restore project filter in export dialog and improve layout

Re-add the project selector dropdown that was removed in the batch
export refactor (#348), allowing users to export conversations from a
specific project. The dialog now uses a flex column layout so the
conversation list fills available space while keeping action buttons
always visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
pionxzh
2026-05-10 05:20:19 +08:00
parent 5cd8986e42
commit e03f32b2a2
2 changed files with 86 additions and 14 deletions

View File

@@ -17,12 +17,13 @@
width: 90vw;
max-width: 560px;
max-height: 85vh;
overflow-x: hidden;
overflow-y: auto;
overflow: hidden;
padding: 16px 24px;
z-index: 1001;
outline: none;
animation: contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1);
display: flex;
flex-direction: column;
}
.dark .DialogContent {
@@ -42,6 +43,7 @@
font-weight: 500;
color: #1a1523;
font-size: 20px;
flex-shrink: 0;
}
.dark .DialogTitle {
@@ -179,6 +181,7 @@
color: inherit;
font-size: 14px;
outline: none;
flex-shrink: 0;
}
.SelectSearch::placeholder {
color: #9ca3af;
@@ -191,12 +194,18 @@
border-radius: 0;
border: 1px solid #6f6e77;
border-bottom: none;
flex-shrink: 0;
}
.ProjectSelect .Select {
width: auto;
}
.SelectList {
position: relative;
width: 100%;
height: 270px;
flex: 1;
min-height: 120px;
padding: 12px 16px;
overflow-x: hidden;
overflow-y: auto;
@@ -257,6 +266,7 @@
border-bottom: none;
background: #f9fafb;
user-select: none;
flex-shrink: 0;
}
.dark {

View File

@@ -1,7 +1,7 @@
import * as Dialog from '@radix-ui/react-dialog'
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
import { useTranslation } from 'react-i18next'
import { archiveConversation, deleteConversation, fetchAllConversations, fetchConversation, fetchConversationsPage, probeApi } from '../api'
import { archiveConversation, deleteConversation, fetchAllConversations, fetchConversation, fetchConversationsPage, fetchProjects, probeApi } from '../api'
import { EXPORT_OPERATION_BATCH } from '../constants'
import { exportAllToHtml } from '../exporter/html'
import { exportAllToJson, exportAllToOfficialJson } from '../exporter/json'
@@ -11,7 +11,7 @@ import { sleep } from '../utils/utils'
import { CheckBox } from './CheckBox'
import { IconCross, IconLoading, IconUpload } from './Icons'
import { useSettingContext } from './SettingContext'
import type { ApiConversationItem, ApiConversationWithId } from '../api'
import type { ApiConversationItem, ApiConversationWithId, ApiProjectInfo } from '../api'
import type { FC } from '../type'
import type { ChangeEvent } from 'preact/compat'
@@ -76,6 +76,45 @@ function textSearch(title: string, query: string): boolean {
}
}
// ---------------------------------------------------------------------------
// ProjectSelect component
// ---------------------------------------------------------------------------
interface ProjectSelectProps {
projects: ApiProjectInfo[]
selected: string | null
setSelected: (projectId: string | null) => void
disabled: boolean
loading: boolean
}
const ProjectSelect: FC<ProjectSelectProps> = ({ projects, selected, setSelected, disabled, loading }) => {
const { t } = useTranslation()
return (
<div className="ProjectSelect flex items-center text-gray-600 dark:text-gray-300 justify-between mb-3">
{t('Select Project')}
<div className="flex items-center gap-2">
{loading && <IconLoading className="w-3 h-3" />}
<select
disabled={disabled}
className="Select"
value={selected ?? ''}
onChange={(e) => {
const val = e.currentTarget.value
setSelected(val || null)
}}
>
<option value="">{t('All conversations')}</option>
{projects.map(project => (
<option key={project.id} value={project.id}>{project.display.name}</option>
))}
</select>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// ConversationSelect component
// ---------------------------------------------------------------------------
@@ -330,6 +369,11 @@ const DialogContent: FC<DialogContentProps> = ({ format }) => {
const [localConversations, setLocalConversations] = useState<ApiConversationWithId[]>([])
const conversations = exportSource === 'API' ? apiConversations : localConversations
const [projects, setProjects] = useState<ApiProjectInfo[]>([])
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
const [projectsLoading, setProjectsLoading] = useState(false)
const selectedProject = projects.find(p => p.id === selectedProjectId) ?? null
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [processing, setProcessing] = useState(false)
@@ -435,7 +479,7 @@ const DialogContent: FC<DialogContentProps> = ({ format }) => {
const partIndex = batchIdx + 1
const callback = exportAllOptions.find(o => o.label === exportType)?.callback
if (callback) {
await callback(format, results, metaList, undefined, partIndex, totalBatches)
await callback(format, results, metaList, selectedProject?.display.name, partIndex, totalBatches)
}
if (partIndex < totalBatches) {
await sleep(400)
@@ -448,7 +492,7 @@ const DialogContent: FC<DialogContentProps> = ({ format }) => {
}
})
return () => off()
}, [requestQueue, exportAllOptions, exportType, format, metaList, startApiBatch])
}, [requestQueue, exportAllOptions, exportType, format, metaList, startApiBatch, selectedProject])
useEffect(() => {
const off = archiveQueue.on('done', () => {
@@ -505,11 +549,11 @@ const DialogContent: FC<DialogContentProps> = ({ format }) => {
const chunks = chunkArray(results, EXPORT_OPERATION_BATCH)
setProcessing(true)
for (let i = 0; i < chunks.length; i++) {
await callback(format, chunks[i], metaList, undefined, i + 1, chunks.length)
await callback(format, chunks[i], metaList, selectedProject?.display.name, i + 1, chunks.length)
if (i < chunks.length - 1) await sleep(400)
}
setProcessing(false)
}, [disabled, selected, localConversations, exportAllOptions, exportType, format, metaList])
}, [disabled, selected, localConversations, exportAllOptions, exportType, format, metaList, selectedProject])
const exportAll = useMemo(() => {
return exportSource === 'API' ? exportAllFromApi : exportAllFromLocal
@@ -552,7 +596,16 @@ const DialogContent: FC<DialogContentProps> = ({ format }) => {
exportingRef.current = processing
}, [processing])
// Auto-load conversations on dialog open
// Fetch projects on mount
useEffect(() => {
setProjectsLoading(true)
fetchProjects()
.then(setProjects)
.catch(err => console.error('Error fetching projects:', err))
.finally(() => setProjectsLoading(false))
}, [])
// Auto-load conversations on dialog open (or when project changes)
useEffect(() => {
const gen = ++fetchGenRef.current
const alive = () => gen === fetchGenRef.current
@@ -562,7 +615,7 @@ const DialogContent: FC<DialogContentProps> = ({ format }) => {
setTotalAvailable(null)
setLoading(true)
fetchAllConversations(
null,
selectedProjectId,
exportAllLimit,
(batch) => { if (alive()) setApiConversations(prev => [...prev, ...batch]) },
(hasMore) => { if (alive()) setHasMore(hasMore) },
@@ -573,13 +626,13 @@ const DialogContent: FC<DialogContentProps> = ({ format }) => {
setError(err.message || 'Failed to load conversations')
})
.finally(() => { if (alive()) setLoading(false) })
}, [exportAllLimit])
}, [exportAllLimit, selectedProjectId])
const loadMore = useCallback(async () => {
if (loadingMore) return
setLoadingMore(true)
try {
const page = await fetchConversationsPage(null, apiConversations.length, EXPORT_OPERATION_BATCH)
const page = await fetchConversationsPage(selectedProjectId, apiConversations.length, EXPORT_OPERATION_BATCH)
setApiConversations(prev => [...prev, ...page.items])
if (page.total !== null) setTotalAvailable(page.total)
setHasMore(
@@ -593,7 +646,7 @@ const DialogContent: FC<DialogContentProps> = ({ format }) => {
finally {
setLoadingMore(false)
}
}, [loadingMore, apiConversations.length])
}, [loadingMore, apiConversations.length, selectedProjectId])
const totalBatches = Math.ceil(selected.length / EXPORT_OPERATION_BATCH) || 1
@@ -664,6 +717,15 @@ const DialogContent: FC<DialogContentProps> = ({ format }) => {
ref={fileInputRef}
onChange={onUpload}
/>
{exportSource === 'API' && (
<ProjectSelect
projects={projects}
selected={selectedProjectId}
setSelected={setSelectedProjectId}
disabled={processing}
loading={projectsLoading}
/>
)}
<ConversationSelect
conversations={conversations}
selected={selected}