| import React, { useCallback, useEffect, useRef, useState } from "react"; |
| import { PlusCircle, GripVertical, Trash2, Eye, EyeOff } from "lucide-react"; |
| import { Reorder, AnimatePresence, motion } from "framer-motion"; |
| import { useTranslations } from "@/i18n/compat/client"; |
| import { Button } from "@/components/ui/button"; |
| import { Input } from "@/components/ui/input"; |
| import { Switch } from "@/components/ui/switch"; |
| import PhotoUpload from "@/components/shared/PhotoSelector"; |
| import IconSelector from "../IconSelector"; |
| import AlignSelector from "./AlignSelector"; |
| import Field from "../Field"; |
| import { cn } from "@/lib/utils"; |
| import { DEFAULT_FIELD_ORDER } from "@/config"; |
| import { useResumeStore } from "@/store/useResumeStore"; |
| import { BasicFieldType, CustomFieldType } from "@/types/resume"; |
| import { generateUUID } from "@/utils/uuid"; |
| interface CustomFieldProps { |
| field: CustomFieldType; |
| onUpdate: (field: CustomFieldType) => void; |
| onDelete: (id: string) => void; |
| onReorderEnd: () => void; |
| } |
|
|
| const itemAnimations = { |
| initial: { opacity: 0, y: 0 }, |
| animate: { opacity: 1, y: 0 }, |
| exit: { opacity: 0, y: 0 }, |
| transition: { type: "spring", stiffness: 500, damping: 50, mass: 1 }, |
| }; |
|
|
| const CustomField: React.FC<CustomFieldProps> = ({ |
| field, |
| onUpdate, |
| onDelete, |
| onReorderEnd, |
| }) => { |
| const t = useTranslations("workbench.basicPanel"); |
|
|
| return ( |
| <Reorder.Item |
| value={field} |
| id={field.id} |
| className="group touch-none list-none" |
| onDragEnd={onReorderEnd} |
| > |
| <motion.div |
| {...itemAnimations} |
| className={cn( |
| "grid grid-cols-[auto,auto,1fr,1fr,auto,auto] gap-3 items-center p-3", |
| "bg-card rounded-xl", |
| "border border-border", |
| "transition-all duration-200", |
| "hover:border-primary/20", |
| !field.visible && "!opacity-60" |
| )} |
| > |
| <div className="flex items-center justify-center"> |
| <GripVertical |
| className={cn( |
| "w-4 h-4 cursor-grab active:cursor-grabbing", |
| "text-muted-foreground", |
| "transition-colors duration-200", |
| "group-hover:text-foreground" |
| )} |
| /> |
| </div> |
| <div className="flex items-center justify-center"> |
| <IconSelector |
| value={field.icon} |
| onChange={(value) => onUpdate({ ...field, icon: value })} |
| /> |
| </div> |
| <Field |
| value={field.label ?? ""} |
| onChange={(value) => |
| onUpdate({ |
| ...field, |
| label: value, |
| }) |
| } |
| placeholder={t("customFields.placeholders.label")} |
| className={cn( |
| "bg-background/50", |
| "border-border", |
| "focus:border-primary", |
| "placeholder-muted-foreground" |
| )} |
| /> |
| <Field |
| value={field.value} |
| onChange={(value) => |
| onUpdate({ |
| ...field, |
| value: value, |
| }) |
| } |
| placeholder={t("customFields.placeholders.value")} |
| className={cn( |
| "bg-background/50", |
| "border-border", |
| "focus:border-primary", |
| "placeholder-muted-foreground" |
| )} |
| /> |
| |
| <div className="flex items-center gap-2 whitespace-nowrap"> |
| <Switch |
| checked={field.displayLabel ?? false} |
| onCheckedChange={(checked) => |
| onUpdate({ |
| ...field, |
| displayLabel: checked, |
| }) |
| } |
| /> |
| <span className="text-xs text-muted-foreground"> |
| {t("customFields.displayLabel")} |
| </span> |
| </div> |
| |
| <div className="flex items-center space-x-1"> |
| <Button |
| variant="ghost" |
| size="sm" |
| className={cn( |
| "shrink-0 h-8 px-2", |
| "text-muted-foreground", |
| "hover:text-foreground" |
| )} |
| onClick={() => onUpdate({ ...field, visible: !field.visible })} |
| > |
| {field.visible ? ( |
| <Eye className="w-4 h-4 text-primary" /> |
| ) : ( |
| <EyeOff className="w-4 h-4" /> |
| )} |
| </Button> |
| |
| <Button |
| variant="ghost" |
| size="sm" |
| onClick={() => onDelete(field.id)} |
| className={cn( |
| "shrink-0 h-8 px-2", |
| "text-neutral-500 dark:text-neutral-400", |
| "hover:text-red-600 dark:hover:text-red-400" |
| )} |
| > |
| <Trash2 className="w-4 h-4 text-red-400" /> |
| </Button> |
| </div> |
| </motion.div> |
| </Reorder.Item> |
| ); |
| }; |
|
|
| const BasicPanel: React.FC = () => { |
| const { activeResume, updateBasicInfo } = useResumeStore(); |
| const { basic } = activeResume || {}; |
| const [customFields, setCustomFields] = useState<CustomFieldType[]>( |
| basic?.customFields?.map((field) => ({ |
| ...field, |
| visible: field.visible ?? true, |
| })) || [] |
| ); |
| const [basicFields, setBasicFields] = useState<BasicFieldType[]>(() => { |
| if (!basic?.fieldOrder) { |
| return DEFAULT_FIELD_ORDER; |
| } |
| return basic.fieldOrder.map((field) => ({ |
| ...field, |
| visible: field.visible ?? true, |
| })); |
| }); |
| const basicFieldsRef = useRef(basicFields); |
| const customFieldsRef = useRef(customFields); |
| const t = useTranslations("workbench.basicPanel"); |
|
|
| useEffect(() => { |
| basicFieldsRef.current = basicFields; |
| }, [basicFields]); |
|
|
| useEffect(() => { |
| customFieldsRef.current = customFields; |
| }, [customFields]); |
|
|
| const handleBasicReorder = (newOrder: BasicFieldType[]) => { |
| basicFieldsRef.current = newOrder; |
| setBasicFields(newOrder); |
| }; |
|
|
| const commitBasicReorder = useCallback(() => { |
| updateBasicInfo({ |
| fieldOrder: basicFieldsRef.current, |
| }); |
| }, [updateBasicInfo]); |
|
|
| const toggleFieldVisibility = (fieldId: string, isVisible: boolean) => { |
| const newFields = basicFields.map((field) => |
| field.id === fieldId ? { ...field, visible: isVisible } : field |
| ); |
| setBasicFields(newFields); |
| updateBasicInfo({ |
| ...basic, |
| fieldOrder: newFields, |
| }); |
| }; |
|
|
| const deleteBasicField = (fieldId: string) => { |
| const fieldToDelete = basicFields.find((field) => field.id === fieldId); |
| if ( |
| fieldToDelete && |
| (fieldToDelete.key === "name" || fieldToDelete.key === "title") |
| ) { |
| return; |
| } |
|
|
| const updatedFields = basicFields.filter((field) => field.id !== fieldId); |
| setBasicFields(updatedFields); |
| updateBasicInfo({ |
| ...basic, |
| fieldOrder: updatedFields, |
| }); |
| }; |
|
|
| const addCustomField = () => { |
| const fieldToAdd: CustomFieldType = { |
| id: generateUUID(), |
| label: "", |
| value: "", |
| icon: "User", |
| visible: true, |
| displayLabel: false, |
| }; |
| const updatedFields = [...customFields, fieldToAdd]; |
| setCustomFields(updatedFields); |
| updateBasicInfo({ |
| ...basic, |
| customFields: updatedFields, |
| }); |
| }; |
|
|
| const updateCustomField = (updatedField: CustomFieldType) => { |
| const updatedFields = customFields.map((field) => |
| field.id === updatedField.id ? updatedField : field |
| ); |
| setCustomFields(updatedFields); |
| updateBasicInfo({ |
| ...basic, |
| customFields: updatedFields, |
| }); |
| }; |
|
|
| const deleteCustomField = (id: string) => { |
| const updatedFields = customFields.filter((field) => field.id !== id); |
| setCustomFields(updatedFields); |
| updateBasicInfo({ |
| ...basic, |
| customFields: updatedFields, |
| }); |
| }; |
|
|
| const handleCustomFieldsReorder = (newOrder: CustomFieldType[]) => { |
| customFieldsRef.current = newOrder; |
| setCustomFields(newOrder); |
| }; |
|
|
| const commitCustomFieldsReorder = useCallback(() => { |
| updateBasicInfo({ |
| customFields: customFieldsRef.current, |
| }); |
| }, [updateBasicInfo]); |
|
|
| const renderBasicField = (field: BasicFieldType) => { |
| const selectedIcon = basic?.icons?.[field.key] || "User"; |
|
|
| return ( |
| <Reorder.Item |
| value={field} |
| id={field.id} |
| key={field.id} |
| className="group touch-none list-none" |
| dragListener={field.key !== "name" && field.key !== "title"} |
| onDragEnd={commitBasicReorder} |
| > |
| <motion.div |
| {...itemAnimations} |
| className={cn( |
| "flex items-center gap-4 p-4 pr-3", |
| "bg-card", |
| "rounded-lg ", |
| "transition-all duration-200", |
| !field.visible && "opacity-75" |
| )} |
| > |
| {field.key !== "name" && field.key !== "title" && ( |
| <div className="shrink-0"> |
| <GripVertical |
| className={cn( |
| "w-5 h-5 cursor-grab active:cursor-grabbing", |
| "text-muted-foreground", |
| "hover:text-foreground", |
| "transition-colors duration-200" |
| )} |
| /> |
| </div> |
| )} |
| |
| <div className="flex flex-1 min-w-0 items-center"> |
| {field.key !== "name" && field.key !== "title" && ( |
| <IconSelector |
| value={selectedIcon} |
| onChange={(value) => { |
| updateBasicInfo({ |
| ...basic, |
| icons: { |
| ...(basic?.icons || {}), |
| [field.key]: value, |
| }, |
| }); |
| }} |
| /> |
| )} |
| <div className=" w-[80px] ml-[4px] text-sm font-medium text-foreground"> |
| {t(`basicFields.${field.key}`)} |
| </div> |
| <div className="flex-1"> |
| <Field |
| label="" |
| value={(basic?.[field.key] as string) ?? ""} |
| onChange={(value) => |
| updateBasicInfo({ |
| ...basic, |
| [field.key]: value, |
| }) |
| } |
| placeholder={`请输入${field.label}`} |
| type={field.type} |
| /> |
| </div> |
| </div> |
| |
| <div className="flex items-center gap-1"> |
| <Button |
| variant="ghost" |
| size="sm" |
| className={cn( |
| "shrink-0 h-8 px-2", |
| "text-neutral-500 dark:text-neutral-400", |
| "hover:text-neutral-700 dark:hover:text-neutral-200" |
| )} |
| onClick={() => toggleFieldVisibility(field.id, !field.visible)} |
| > |
| {field.visible ? ( |
| <Eye className="w-4 h-4 text-primary" /> |
| ) : ( |
| <EyeOff className="w-4 h-4" /> |
| )} |
| </Button> |
| |
| {field.key !== "name" && field.key !== "title" && ( |
| <Button |
| variant="ghost" |
| size="sm" |
| className={cn( |
| "shrink-0 h-8 px-2", |
| "text-neutral-500 dark:text-neutral-400", |
| "hover:text-red-600 dark:hover:text-red-400" |
| )} |
| onClick={() => deleteBasicField(field.id)} |
| > |
| <Trash2 className="w-4 h-4 text-red-400" /> |
| </Button> |
| )} |
| </div> |
| </motion.div> |
| </Reorder.Item> |
| ); |
| }; |
|
|
| return ( |
| <div className="space-y-6 p-6"> |
| <div className="space-y-6"> |
| <div className="space-y-2"> |
| <h2 className="text-lg font-medium">{t("layout")}</h2> |
| <div className=" bg-card rounded-lg"> |
| <AlignSelector |
| value={basic?.layout || "left"} |
| onChange={(value) => |
| updateBasicInfo({ |
| ...basic, |
| layout: value, |
| }) |
| } |
| /> |
| </div> |
| </div> |
| |
| <div className="space-y-4"> |
| <div className="flex items-center justify-between"> |
| <h2 className="text-lg font-medium">{t("title")}</h2> |
| </div> |
| |
| <div className="space-y-4"> |
| <motion.div className="space-y-6"> |
| <motion.div |
| initial={{ opacity: 0, y: 0 }} |
| animate={{ opacity: 1, y: 0 }} |
| className="bg-card rounded-xl p-3 border border-border" |
| > |
| <PhotoUpload /> |
| </motion.div> |
| |
| <motion.div className="space-y-6"> |
| <motion.div className="space-y-3"> |
| <motion.h3 className="font-medium text-neutral-900 dark:text-neutral-200 px-1"> |
| {t("basicField")} |
| </motion.h3> |
| <AnimatePresence mode="popLayout"> |
| <Reorder.Group |
| axis="y" |
| as="div" |
| values={basicFields} |
| onReorder={handleBasicReorder} |
| className="space-y-3" |
| > |
| {basicFields.map((field) => renderBasicField(field))} |
| </Reorder.Group> |
| </AnimatePresence> |
| </motion.div> |
| |
| <motion.div className="space-y-3"> |
| <motion.h3 className="font-medium text-neutral-900 dark:text-neutral-200 px-1"> |
| {t("customField")} |
| </motion.h3> |
| <AnimatePresence mode="popLayout"> |
| <Reorder.Group |
| axis="y" |
| as="div" |
| values={customFields} |
| onReorder={handleCustomFieldsReorder} |
| className="space-y-3" |
| > |
| {Array.isArray(customFields) && |
| customFields.map((field) => ( |
| <CustomField |
| key={field.id} |
| field={field} |
| onUpdate={updateCustomField} |
| onDelete={deleteCustomField} |
| onReorderEnd={commitCustomFieldsReorder} |
| /> |
| ))} |
| </Reorder.Group> |
| </AnimatePresence> |
| <motion.div |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| transition={{ delay: 0.2 }} |
| > |
| <Button onClick={addCustomField} className="w-full mt-4"> |
| <PlusCircle className="w-4 h-4 mr-2" /> |
| {t("customFields.addButton")} |
| </Button> |
| </motion.div> |
| </motion.div> |
| <motion.div className="space-y-3"> |
| <motion.div |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| transition={{ delay: 0.2 }} |
| > |
| <div className="flex items-center justify-between"> |
| <motion.h3 className="font-medium text-neutral-900 dark:text-neutral-200 px-1"> |
| {t("githubContributions")} |
| </motion.h3> |
| |
| <Switch |
| checked={basic?.githubContributionsVisible} |
| onCheckedChange={(checked) => |
| updateBasicInfo({ |
| ...basic, |
| githubContributionsVisible: checked, |
| }) |
| } |
| /> |
| </div> |
| |
| <div className="mt-4"> |
| <div className="flex items-center ml-3 space-x-2"> |
| <div className=" w-[110px]">Access Token</div> |
| <Input |
| placeholder="请输入github access token" |
| className="flex-1" |
| value={basic?.githubKey} |
| onChange={(e) => |
| updateBasicInfo({ |
| ...basic, |
| githubKey: e.target.value, |
| }) |
| } |
| /> |
| </div> |
| <div className="flex items-center ml-3 mt-4 space-x-2"> |
| <div className="w-[110px]">UseName</div> |
| <Input |
| className="flex-1" |
| placeholder="请输入github username" |
| value={basic?.githubUseName} |
| onChange={(e) => |
| updateBasicInfo({ |
| ...basic, |
| githubUseName: e.target.value, |
| }) |
| } |
| /> |
| </div> |
| </div> |
| </motion.div> |
| </motion.div> |
| </motion.div> |
| </motion.div> |
| </div> |
| </div> |
| </div> |
| </div> |
| ); |
| }; |
|
|
| export default BasicPanel; |
|
|