import React from 'react'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { toast } from '@/components/ui'; import { cn } from '@/lib/utils'; import { PROJECT_ICONS, PROJECT_COLORS, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useI18n } from '@/lib/i18n'; interface ProjectEditDialogProps { open: boolean; onOpenChange: (open: boolean) => void; projectId: string; projectName: string; projectPath: string; initialIcon?: string | null; initialColor?: string | null; initialIconBackground?: string | null; onSave: (data: { label: string; icon: string | null; color: string | null; iconBackground: string | null }) => void; } const HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/; const normalizeIconBackground = (value: string | null): string | null => { if (!value) { return null; } const trimmed = value.trim(); if (!trimmed) { return null; } return HEX_COLOR_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null; }; export const ProjectEditDialog: React.FC = ({ open, onOpenChange, projectId, projectName, projectPath, initialIcon = null, initialColor = null, initialIconBackground = null, onSave, }) => { const { t } = useI18n(); const uploadProjectIcon = useProjectsStore((state) => state.uploadProjectIcon); const removeProjectIcon = useProjectsStore((state) => state.removeProjectIcon); const discoverProjectIcon = useProjectsStore((state) => state.discoverProjectIcon); const currentIconImage = useProjectsStore((state) => state.projects.find((project) => project.id === projectId)?.iconImage ?? null); const { currentTheme } = useThemeSystem(); const [name, setName] = React.useState(projectName); const [icon, setIcon] = React.useState(initialIcon); const [color, setColor] = React.useState(initialColor); const [iconBackground, setIconBackground] = React.useState(normalizeIconBackground(initialIconBackground)); const [isUploadingIcon, setIsUploadingIcon] = React.useState(false); const [isRemovingCustomIcon, setIsRemovingCustomIcon] = React.useState(false); const [isDiscoveringIcon, setIsDiscoveringIcon] = React.useState(false); const [pendingRemoveImageIcon, setPendingRemoveImageIcon] = React.useState(false); const [pendingUploadIconFile, setPendingUploadIconFile] = React.useState(null); const [pendingUploadIconPreviewUrl, setPendingUploadIconPreviewUrl] = React.useState(null); const [previewImageFailed, setPreviewImageFailed] = React.useState(false); const fileInputRef = React.useRef(null); const clearPendingUploadIcon = React.useCallback(() => { setPendingUploadIconFile(null); setPendingUploadIconPreviewUrl((previousUrl) => { if (previousUrl) { URL.revokeObjectURL(previousUrl); } return null; }); }, []); React.useEffect(() => { if (open) { setName(projectName); setIcon(initialIcon); setColor(initialColor); setIconBackground(normalizeIconBackground(initialIconBackground)); setPendingRemoveImageIcon(false); clearPendingUploadIcon(); setPreviewImageFailed(false); } }, [open, projectName, initialIcon, initialColor, initialIconBackground, clearPendingUploadIcon]); React.useEffect(() => { return () => { clearPendingUploadIcon(); }; }, [clearPendingUploadIcon]); const handleSave = async () => { const trimmed = name.trim(); if (!trimmed) return; if (pendingUploadIconFile) { setIsUploadingIcon(true); const uploadResult = await uploadProjectIcon(projectId, pendingUploadIconFile); setIsUploadingIcon(false); if (!uploadResult.ok) { toast.error(uploadResult.error || t('projectEditDialog.toast.failedToUploadIcon')); return; } toast.success(t('projectEditDialog.toast.iconUpdated')); clearPendingUploadIcon(); setPendingRemoveImageIcon(false); } const willRemoveImageIcon = pendingRemoveImageIcon && hasStoredImageIcon; if (willRemoveImageIcon) { setIsRemovingCustomIcon(true); const result = await removeProjectIcon(projectId); setIsRemovingCustomIcon(false); if (!result.ok) { toast.error(result.error || t('projectEditDialog.toast.failedToRemoveIcon')); return; } toast.success(t('projectEditDialog.toast.iconRemoved')); setPendingRemoveImageIcon(false); setIconBackground(null); } onSave({ label: trimmed, icon, color, iconBackground: normalizeIconBackground(willRemoveImageIcon ? null : iconBackground), }); onOpenChange(false); }; const currentColorVar = color ? (PROJECT_COLOR_MAP[color] ?? null) : null; const hasStoredImageIcon = Boolean(currentIconImage); const hasPendingUploadImageIcon = Boolean(pendingUploadIconFile && pendingUploadIconPreviewUrl); const hasCustomIcon = currentIconImage?.source === 'custom'; const effectiveHasImageIcon = (hasStoredImageIcon && !pendingRemoveImageIcon) || hasPendingUploadImageIcon; const hasRemovableImageIcon = effectiveHasImageIcon; const iconPreviewUrl = !previewImageFailed ? (hasPendingUploadImageIcon ? pendingUploadIconPreviewUrl : (hasStoredImageIcon && !pendingRemoveImageIcon ? getProjectIconImageUrl( { id: projectId, iconImage: currentIconImage ?? null }, { themeVariant: currentTheme.metadata.variant, iconColor: currentTheme.colors.surface.foreground, }, ) : null)) : null; React.useEffect(() => { setPreviewImageFailed(false); }, [projectId, currentIconImage?.updatedAt]); const handleUploadIcon = React.useCallback((file: File | null) => { if (!projectId || !file || isUploadingIcon) { return; } setPendingRemoveImageIcon(false); setPreviewImageFailed(false); setPendingUploadIconFile(file); setPendingUploadIconPreviewUrl((previousUrl) => { if (previousUrl) { URL.revokeObjectURL(previousUrl); } return URL.createObjectURL(file); }); }, [isUploadingIcon, projectId]); const handleRemoveImageIcon = React.useCallback(() => { if (!projectId || !hasRemovableImageIcon || isRemovingCustomIcon) { return; } if (hasPendingUploadImageIcon) { clearPendingUploadIcon(); } if (hasStoredImageIcon) { setPendingRemoveImageIcon(true); } else { setPendingRemoveImageIcon(false); } setPreviewImageFailed(false); }, [ clearPendingUploadIcon, hasPendingUploadImageIcon, hasRemovableImageIcon, hasStoredImageIcon, isRemovingCustomIcon, projectId, ]); const handleDiscoverIcon = React.useCallback(async () => { if (!projectId || isDiscoveringIcon) { return; } clearPendingUploadIcon(); setPendingRemoveImageIcon(false); setPreviewImageFailed(false); setIsDiscoveringIcon(true); void discoverProjectIcon(projectId) .then((result) => { if (!result.ok) { toast.error(result.error || t('projectEditDialog.toast.failedToDiscoverIcon')); return; } if (result.skipped) { toast.success(t('projectEditDialog.toast.customIconAlreadySet')); return; } toast.success(t('projectEditDialog.toast.iconDiscovered')); }) .finally(() => { setIsDiscoveringIcon(false); }); }, [clearPendingUploadIcon, discoverProjectIcon, isDiscoveringIcon, projectId, t]); return ( {t('projectEditDialog.title')}
{/* Name */}
setName(e.target.value)} placeholder={t('projectEditDialog.field.namePlaceholder')} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSave(); } }} autoFocus />

{projectPath}

{/* Color */}
{/* No color option */} {PROJECT_COLORS.map((c) => (
{/* Icon */}
{ const file = event.target.files?.[0] ?? null; void handleUploadIcon(file); event.currentTarget.value = ''; }} />
{/* No icon option */} {PROJECT_ICONS.map((i) => { const IconComponent = i.Icon; return ( ); })}
{effectiveHasImageIcon && iconPreviewUrl && (
{t('projectEditDialog.field.preview')} setPreviewImageFailed(true)} />
)}
{!hasCustomIcon && ( <> )} {hasRemovableImageIcon && ( )} {pendingRemoveImageIcon && ( )}
{effectiveHasImageIcon && (
setIconBackground(event.target.value)} className="h-8 w-10 cursor-pointer rounded border border-border bg-transparent p-1" aria-label={t('projectEditDialog.field.iconBackgroundAria')} /> setIconBackground(event.target.value)} placeholder="#000000" className="h-8 w-[8.5rem]" />
)}
); };