mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
update
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
//
|
||||
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
import * as React from 'react'
|
||||
import { Mail, Database } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { useAccountContext } from '../context'
|
||||
|
||||
export type AddAccountType = 'IMAP' | 'NoSync'
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function AddAccountDialog({
|
||||
open,
|
||||
onOpenChange
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
const { setOpen } = useAccountContext()
|
||||
const [value, setValue] = React.useState<AddAccountType>('IMAP')
|
||||
|
||||
function handleContinue() {
|
||||
if (value === 'IMAP') {
|
||||
setOpen('add-imap')
|
||||
} else {
|
||||
setOpen('add-nosync')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader className="text-left">
|
||||
<DialogTitle>
|
||||
{t('accounts.add')}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
{t('accounts.selectAccountType')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<RadioGroup
|
||||
value={value}
|
||||
onValueChange={(v) => setValue(v as AddAccountType)}
|
||||
className="space-y-4 py-2"
|
||||
>
|
||||
<Label
|
||||
htmlFor="imap-account"
|
||||
className={cn(
|
||||
'flex cursor-pointer items-start gap-4 rounded-2xl border p-5 transition-all',
|
||||
value === 'IMAP'
|
||||
? 'border-primary bg-muted/50'
|
||||
: 'hover:bg-muted/30'
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value="IMAP"
|
||||
id="imap-account"
|
||||
className="mt-1"
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 gap-4">
|
||||
<div className="rounded-xl border p-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">
|
||||
{t('accounts.imapAccount')}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('accounts.imapAccountDescription')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Label
|
||||
htmlFor="nosync-account"
|
||||
className={cn(
|
||||
'flex cursor-pointer items-start gap-4 rounded-2xl border p-5 transition-all',
|
||||
value === 'NoSync'
|
||||
? 'border-primary bg-muted/50'
|
||||
: 'hover:bg-muted/30'
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value="NoSync"
|
||||
id="nosync-account"
|
||||
className="mt-1"
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 gap-4">
|
||||
<div className="rounded-xl border p-2">
|
||||
<Database className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">
|
||||
{t('accounts.noSyncAccount')}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t('accounts.noSyncAccountDescription')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Label>
|
||||
</RadioGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleContinue}>
|
||||
{t('accounts.continue')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion"
|
||||
import {
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Loader2,
|
||||
Activity,
|
||||
@@ -55,7 +54,6 @@ interface Props {
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
// 保持颜色逻辑,但在暗色模式下这些颜色也相对友好,如果需要完全适配可调整为 bg-primary/10 等
|
||||
const map: Record<string, string> = {
|
||||
Running: 'bg-blue-500/10 text-blue-600',
|
||||
Downloading: 'bg-blue-500/10 text-blue-600',
|
||||
@@ -141,7 +139,6 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
|
||||
const session = state?.active_session
|
||||
const history = state?.history || []
|
||||
const globalErrors = state?.global_errors || []
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -168,10 +165,6 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
{t('accounts.runningState.tabs.history')}
|
||||
<Badge variant="secondary" className="ml-2 h-4 px-1 text-[10px] font-bold">{history.length}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="errors" className="whitespace-nowrap data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none h-full bg-transparent shadow-none px-0 text-xs sm:text-sm font-bold">
|
||||
{t('accounts.runningState.tabs.global_errors')}
|
||||
{globalErrors.length > 0 && <Badge variant="destructive" className="ml-2 h-4 px-1 text-[10px] font-bold">{globalErrors.length}</Badge>}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
@@ -395,82 +388,6 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
<TabsContent value="errors" className="h-full m-0 data-[state=active]:flex flex-col">
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 sm:p-6">
|
||||
{globalErrors.length === 0 ? (
|
||||
<div className="py-32 text-center text-muted-foreground">
|
||||
<CheckCircle className="w-12 h-12 mx-auto mb-2 opacity-20" />
|
||||
<p className="font-medium italic text-sm">{t('accounts.runningState.empty.no_global_errors')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="absolute left-[19px] top-2 bottom-2 w-0.5 bg-destructive/20" />
|
||||
<div className="space-y-6">
|
||||
{[...globalErrors]
|
||||
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
|
||||
.map((e, i) => (
|
||||
<div key={i} className="relative pl-10 min-w-0">
|
||||
<div className="absolute left-0 top-1.5 w-[40px] flex justify-center">
|
||||
{i === 0 ? (
|
||||
<span className="relative flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-destructive opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-destructive"></span>
|
||||
</span>
|
||||
) : (
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-destructive/20 mt-0.5" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`
|
||||
p-4 border rounded-2xl shadow-sm transition-all min-w-0
|
||||
${i === 0
|
||||
? 'border-destructive/20 bg-destructive/5 ring-1 ring-destructive/10'
|
||||
: 'border-border bg-card'}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-2 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
|
||||
{i === 0 && (
|
||||
<Badge className="bg-destructive hover:bg-destructive text-[9px] h-4 px-1">
|
||||
{t('accounts.runningState.latest')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<div className="text-[10px] font-mono font-bold text-destructive bg-destructive/10 px-1.5 py-0.5 rounded break-all">
|
||||
<span className="sm:hidden">
|
||||
{format(new Date(e.at), 'HH:mm')}
|
||||
</span>
|
||||
<span className="hidden sm:inline">
|
||||
{format(new Date(e.at), 'yyyy-MM-dd HH:mm:ss')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertTriangle
|
||||
className={`w-4 h-4 shrink-0 ${i === 0 ? 'text-destructive' : 'text-destructive/50'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={`
|
||||
text-xs font-bold leading-relaxed whitespace-pre-wrap break-all min-w-0
|
||||
${i === 0 ? 'text-foreground' : 'text-muted-foreground'}
|
||||
`}
|
||||
>
|
||||
{e.error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { AccountModel } from '@/api/account/api';
|
||||
import React from 'react'
|
||||
|
||||
export type AccountDialogType =
|
||||
| 'add'
|
||||
| 'add-imap'
|
||||
| 'add-nosync'
|
||||
| 'edit-imap'
|
||||
|
||||
@@ -43,6 +43,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AccountAccessAssignmentDialog } from './components/access-assignment-dialog'
|
||||
import { useCurrentUser } from '@/hooks/use-current-user'
|
||||
import { AddAccountDialog } from './components/add-account-dialog'
|
||||
|
||||
export default function Accounts() {
|
||||
const { t } = useTranslation()
|
||||
@@ -75,29 +76,12 @@ export default function Accounts() {
|
||||
{require_any_permission(['system:root', 'account:create']) && <div className="flex gap-2">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<Button
|
||||
onClick={() => setOpen("add-imap")}
|
||||
onClick={() => setOpen("add")}
|
||||
className="rounded-r-none border-r-0"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('accounts.addImap')}
|
||||
{t('accounts.add')}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-l-none border-l-0"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">{t('accounts.moreAccountTypes')}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setOpen("add-nosync")}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('accounts.addNoSync')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
@@ -120,8 +104,8 @@ export default function Accounts() {
|
||||
{t('accounts.noAccountConfigurationsDesc')}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-col items-center gap-3 sm:flex-row sm:flex-wrap sm:justify-center sm:gap-4">
|
||||
<Button variant="default" className="w-64" onClick={() => setOpen("add-imap")}>
|
||||
{t('accounts.addConfiguration')}
|
||||
<Button variant="default" className="w-64" onClick={() => setOpen("add")}>
|
||||
{t('accounts.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,7 +114,10 @@ export default function Accounts() {
|
||||
</div>
|
||||
</div>
|
||||
</Main>
|
||||
|
||||
<AddAccountDialog
|
||||
key='account-add'
|
||||
open={open === 'add'}
|
||||
onOpenChange={() => setOpen('add')} />
|
||||
|
||||
<AccountActionDialog
|
||||
key='imap-account-add'
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { ChevronDown, ChevronUp, Loader2, MessageSquareText } from 'lucide-react';
|
||||
import { Loader2, MessageSquareText } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
@@ -27,7 +27,9 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { get_thread_messages } from '@/api/mailbox/envelope/api';
|
||||
import { MailMessageView } from './mail-message-view';
|
||||
@@ -72,6 +74,7 @@ export function MailThreadDialog({ open, onOpenChange }: MailThreadDialogProps)
|
||||
|
||||
const allMessages = data?.pages.flatMap((page) => page.items) ?? [];
|
||||
const totalCount = data?.pages[0]?.total_items ?? 0;
|
||||
const sortedMessages = [...allMessages].sort((a, b) => a.date - b.date);
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
@@ -84,138 +87,168 @@ export function MailThreadDialog({ open, onOpenChange }: MailThreadDialogProps)
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full max-width-full p-0 max-h-full flex flex-col md:max-w-3xl lg:max-w-4xl">
|
||||
{/* Header */}
|
||||
<DialogHeader className="p-4 pb-3 border-b shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageSquareText className="w-5 h-5" />
|
||||
<div className="text-sm">
|
||||
{t('search.thread.title', { count: totalCount })}
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageSquareText className="w-5 h-5" />
|
||||
<span className="text-sm">
|
||||
{t('search.thread.title', { count: totalCount })}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{isLoading && <ThreadSkeleton />}
|
||||
<ScrollArea className="h-[calc(100vh-260px)] w-full pr-4 -mr-4 py-1">
|
||||
<div className="p-4 sm:p-6">
|
||||
{isLoading && <ThreadSkeleton />}
|
||||
|
||||
{isError && (
|
||||
<div className="text-center text-destructive text-sm">
|
||||
{t('search.thread.error')}: {(error as Error)?.message}
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<div className="text-center text-destructive text-sm">
|
||||
{t('search.thread.error')}: {(error as Error)?.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && allMessages.length === 0 && (
|
||||
<div className="text-center text-muted-foreground text-sm">
|
||||
{t('search.thread.empty')}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && sortedMessages.length === 0 && (
|
||||
<div className="text-center text-muted-foreground text-sm">
|
||||
{t('search.thread.empty')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allMessages
|
||||
.sort((a, b) => a.date - b.date)
|
||||
.map((msg) => {
|
||||
const isExpanded = expandedIds.has(msg.id);
|
||||
const preview = msg.preview;
|
||||
const date = new Date(msg.date);
|
||||
const formattedDate = isNaN(date.getTime())
|
||||
? t('search.thread.invalidDate')
|
||||
: format(date, 'yyyy-MM-dd HH:mm:ss');
|
||||
{sortedMessages.length > 0 && (
|
||||
<div className="relative">
|
||||
<div className="absolute left-[19px] top-2 bottom-2 w-0.5 bg-destructive/20" />
|
||||
<div className="absolute left-[15px] bottom-0 w-0 h-0 border-l-[5px] border-r-[5px] border-t-[7px] border-l-transparent border-r-transparent border-t-destructive/40" />
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={msg.id}
|
||||
className={`transition-all ${isExpanded ? 'ring-2 ring-primary' : ''}`}
|
||||
>
|
||||
<CardHeader
|
||||
className="cursor-pointer pb-3"
|
||||
onClick={() => toggleExpand(msg.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium truncate">{msg.from}</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="text-muted-foreground truncate">
|
||||
{msg.to.join(', ')}
|
||||
</span>
|
||||
<div className="space-y-6">
|
||||
{sortedMessages.map((msg, i) => {
|
||||
const isExpanded = expandedIds.has(msg.id);
|
||||
const isLatest = i === sortedMessages.length - 1;
|
||||
|
||||
const date = new Date(msg.date);
|
||||
const formattedDate = isNaN(date.getTime())
|
||||
? t('search.thread.invalidDate')
|
||||
: format(date, 'yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={`relative pl-10 min-w-0 ${isLatest ? 'mt-6' : ''}`}>
|
||||
<div className="absolute left-0 top-1.5 w-[40px] flex flex-col items-center gap-1">
|
||||
{isLatest && (
|
||||
<Badge className="bg-primary hover:bg-primary text-[9px] h-4 px-1 shrink-0 w-fit">
|
||||
{t('search.thread.latest')}
|
||||
</Badge>
|
||||
)}
|
||||
{isLatest ? (
|
||||
<span className="relative flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-primary" />
|
||||
</span>
|
||||
) : (
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-muted-foreground/30 mt-0.5" />
|
||||
)}
|
||||
</div>
|
||||
<p className="font-medium mt-1 text-sm">
|
||||
{msg.subject || t('search.thread.noSubject')}
|
||||
</p>
|
||||
{!isExpanded && preview && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{preview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Card
|
||||
className={`transition-all min-w-0 shadow-sm ${isLatest
|
||||
? 'border-primary/20 bg-primary/5 ring-1 ring-primary/10'
|
||||
: 'border-border bg-card'
|
||||
} ${isExpanded ? 'ring-2 ring-primary' : ''}`}
|
||||
>
|
||||
<CardHeader
|
||||
className="cursor-pointer pb-3"
|
||||
onClick={() => toggleExpand(msg.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
|
||||
<span className="font-medium truncate text-sm">{msg.from}</span>
|
||||
<span className="text-muted-foreground text-sm">→</span>
|
||||
<span className="text-muted-foreground truncate text-sm">
|
||||
{msg.to.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] font-mono font-bold text-muted-foreground bg-muted px-1.5 py-0.5 rounded shrink-0">
|
||||
<span className="sm:hidden">
|
||||
{isNaN(date.getTime()) ? '' : format(date, 'HH:mm')}
|
||||
</span>
|
||||
<span className="hidden sm:inline">{formattedDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{formattedDate}</span>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<p className="font-medium text-sm">
|
||||
{msg.subject || t('search.thread.noSubject')}
|
||||
</p>
|
||||
|
||||
{isExpanded && (
|
||||
<CardContent className="p-0">
|
||||
<div className="h-96 border-t m-5">
|
||||
<MailMessageView
|
||||
envelope={msg}
|
||||
showActions={false}
|
||||
showAttachments={false}
|
||||
showHeader={false}
|
||||
/>
|
||||
{!isExpanded && msg.preview && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{msg.preview}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{isExpanded && (
|
||||
<CardContent className="p-0">
|
||||
<div className="h-96 border-t m-5">
|
||||
<MailMessageView
|
||||
envelope={msg}
|
||||
showActions={false}
|
||||
showAttachments={false}
|
||||
showHeader={false}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasNextPage && (
|
||||
<div className="flex justify-center py-3 mt-4">
|
||||
<Button
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{t('search.thread.loadingMore')}
|
||||
</>
|
||||
) : (
|
||||
t('search.thread.loadMore')
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{hasNextPage && (
|
||||
<div className="flex justify-center py-3">
|
||||
<Button
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{t('search.thread.loadingMore')}
|
||||
</>
|
||||
) : (
|
||||
t('search.thread.loadMore')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Skeleton
|
||||
function ThreadSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-4 w-48 mb-2" />
|
||||
<Skeleton className="h-5 w-64 mb-1" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-32 mt-2" />
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
<div className="relative">
|
||||
<div className="absolute left-[19px] top-2 bottom-2 w-0.5 bg-destructive/20" />
|
||||
<div className="absolute left-[15px] bottom-0 w-0 h-0 border-l-[5px] border-r-[5px] border-t-[7px] border-l-transparent border-r-transparent border-t-destructive/40" />
|
||||
<div className="space-y-6">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="relative pl-10 min-w-0">
|
||||
<div className="absolute left-0 top-1.5 w-[40px] flex justify-center">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-muted-foreground/30 mt-0.5" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-4 w-48 mb-2" />
|
||||
<Skeleton className="h-5 w-64 mb-1" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-32 mt-2" />
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user