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

311 lines
9.8 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
);
}