casualroom/apps/fenghuo/web/components/editor/editor-sidebar.tsx

311 lines
9.8 KiB
TypeScript
Raw Normal View History

2025-07-28 07:50:50 +08:00
'use client';
import { useRouter } from 'next/navigation';
import { Button } from '@nice/ui/components/button';
import { Card, CardContent, CardHeader, CardTitle } from '@nice/ui/components/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
} from '@nice/ui/components/dialog';
import { Input } from '@nice/ui/components/input';
import { Label } from '@nice/ui/components/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@nice/ui/components/select';
import { Separator } from '@nice/ui/components/separator';
import { Badge } from '@nice/ui/components/badge';
import { Textarea } from '@nice/ui/components/textarea';
import { Switch } from '@nice/ui/components/switch';
import { IconArrowLeft, IconCheck, IconClock, IconDeviceFloppy, IconSettings, IconCalendar } from '@tabler/icons-react';
import { DateTimePicker } from '@nice/ui/components/date-time-picker';
import { useQuery } from '@tanstack/react-query';
import { useTRPC } from '@fenghuo/client';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/zh-cn';
import { DeptSelect } from '../selector/dept-select';
import { TermSelect } from '../selector/term-select';
import { PostStatus, PostType } from '@fenghuo/common/post';
import { useEditorContext } from '@/components/articles/context';
import { useIsClient } from '@/hooks/use-is-client';
dayjs.extend(relativeTime);
dayjs.locale('zh-cn');
interface EditorSidebarProps {
className?: string;
}
export function EditorSidebar({ className }: EditorSidebarProps) {
const router = useRouter();
const trpc = useTRPC();
const isClient = useIsClient();
// 使用编辑器Context
const { state, updateField, save } = useEditorContext();
// 获取分类数据
const { data: taxonomiesData } = useQuery({
...trpc.taxonomy.findMany.queryOptions({
where: {
postTypes: {
has: PostType.ARTICLE,
},
},
}),
});
const handleBack = () => {
router.back();
};
const handleSave = (options?: { newStatus?: PostStatus; successMessage?: string }) => {
save(options);
};
const handleTermChange = (taxonomyId: string, termId: string | string[] | null) => {
// 只处理单选情况如果是数组取第一个值如果是空数组则为null
const finalTermId = Array.isArray(termId) ? (termId.length > 0 ? termId[0] : null) : termId;
const otherTerms = state.terms.filter((t) => t.taxonomyId !== taxonomyId);
const newTerms = finalTermId ? [...otherTerms, { id: finalTermId, taxonomyId }] : otherTerms;
updateField('terms', newTerms);
};
const formatTime = (date: Date) => {
return dayjs(date).fromNow();
};
const renderAutoSaveStatus = () => {
if (state.isSaving) {
return (
<div className="flex items-center gap-2 text-blue-600">
<IconClock className="h-4 w-4 animate-spin" />
<span className="text-sm">...</span>
</div>
);
}
if (state.lastSaved && !state.hasUnsavedChanges) {
return (
<div className="flex items-center gap-2 text-green-600">
<IconCheck className="h-4 w-4" />
<span className="text-sm">{isClient ? `已保存 ${formatTime(state.lastSaved)}` : '已保存'}</span>
</div>
);
}
return (
<div className="flex items-center gap-2 text-muted-foreground">
<span className="text-sm">{state.hasUnsavedChanges ? '有未保存的更改' : '未保存更改'}</span>
</div>
);
};
return (
<div className={`w-96 bg-background border-r border-border flex flex-col ${className}`}>
<div className="p-4 border-b border-border">
<div className="flex items-center justify-between mb-4">
<Button variant="ghost" size="sm" onClick={handleBack} className="h-8 px-2">
<IconArrowLeft className="h-4 w-4 mr-2" />
</Button>
<Badge variant={state.status === PostStatus.PUBLISHED ? 'default' : 'secondary'}>
{state.status === PostStatus.DRAFT ? '草稿' : state.status === PostStatus.PUBLISHED ? '已发布' : '已归档'}
</Badge>
</div>
<div className="mb-4">{renderAutoSaveStatus()}</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => handleSave()} className="flex-1" disabled={state.isSaving}>
<IconDeviceFloppy className="h-4 w-4 mr-2" />
</Button>
{state.status === PostStatus.DRAFT && (
<Dialog>
<DialogTrigger asChild>
<Button size="sm" className="flex-1" disabled={state.isSaving}>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline"></Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={() => {
handleSave({
newStatus: PostStatus.PUBLISHED,
successMessage: '文章已发布',
});
}}
>
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<IconSettings className="h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title" className="text-xs font-medium">
</Label>
<Input
id="title"
value={state.title}
onChange={(e) => updateField('title', e.target.value)}
placeholder="请输入文章标题"
className="text-sm"
required={state.status === PostStatus.PUBLISHED || state.status === PostStatus.ARCHIVED}
/>
</div>
<div className="space-y-2">
<Label htmlFor="excerpt" className="text-xs font-medium">
</Label>
<Textarea
id="excerpt"
value={state.excerpt}
onChange={(e) => updateField('excerpt', e.target.value)}
placeholder="请输入文章摘要"
className="text-sm min-h-[80px] resize-none"
rows={3}
/>
</div>
<Separator />
<div className="space-y-4">
{isClient &&
taxonomiesData?.map((taxonomy) => {
const selectedTermId = state.terms.find((t) => t.taxonomyId === taxonomy.id)?.id;
return (
<div key={taxonomy.id} className="space-y-2">
<Label className="text-xs font-medium">{taxonomy.name}</Label>
<TermSelect
taxonomySlug={taxonomy.slug}
value={selectedTermId ?? ''}
onValueChange={(value) => handleTermChange(taxonomy.id, value)}
placeholder={`选择${taxonomy.name}`}
className="w-full"
allowClear
/>
</div>
);
})}
</div>
<div className="space-y-2">
<Label className="text-xs font-medium">
{state.status !== PostStatus.DRAFT && <span className="text-red-500 ml-1">*</span>}
</Label>
<DeptSelect
value={state.organizationId}
onValueChange={(value) => updateField('organizationId', value as string)}
placeholder="选择单位"
className="w-full"
allowClear
/>
</div>
<Separator />
<div className="space-y-2">
<Label className="text-xs font-medium"></Label>
<Select value={state.status} onValueChange={(value) => updateField('status', value as PostStatus)}>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={PostStatus.DRAFT}>稿</SelectItem>
<SelectItem value={PostStatus.PUBLISHED}></SelectItem>
<SelectItem value={PostStatus.ARCHIVED}></SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
<div className="space-y-2">
<Label className="text-xs font-medium flex items-center gap-2">
<IconCalendar className="h-3 w-3" />
</Label>
<DateTimePicker
value={state.publishedAt || null}
onChange={(date) => updateField('publishedAt', date || undefined)}
placeholder="选择发布时间"
showTime={true}
showClear={true}
size="sm"
className="w-full text-sm"
minDate={isClient ? new Date() : undefined}
dateFormat="YYYY-MM-DD"
timeFormat="HH:mm"
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<Label htmlFor="sticky" className="text-xs font-medium">
</Label>
<Switch
id="sticky"
checked={state.order > 0}
onCheckedChange={(checked) => updateField('order', checked ? Date.now() : 0)}
/>
</div>
</CardContent>
</Card>
{state.id && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">ID:</span>
<span className="font-mono">{state.id}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">:</span>
<span>{state.content ? state.content.replace(/<[^>]*>/g, '').length : 0} </span>
</div>
</CardContent>
</Card>
)}
</div>
</div>
);
}