287 lines
10 KiB
TypeScript
287 lines
10 KiB
TypeScript
import React from 'react';
|
|||
|
|
import { Input } from '@/components/ui/input';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import { Icon } from '@/components/icon/Icon';
|
||
|
|
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
|
||
|
|
import { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
||
|
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||
|
|
import { useI18n } from '@/lib/i18n';
|
||
|
|
import { cn } from '@/lib/utils';
|
||
|
|
import {
|
||
|
|
PROJECT_SETTINGS_CONTROL_WIDTH,
|
||
|
|
ProjectSettingsSubsection,
|
||
|
|
} from '@/components/sections/projects/ProjectSettingsSubsection';
|
||
|
|
import type { useProjectIdentityForm } from './useProjectIdentityForm';
|
||
|
|
|
||
|
|
type ProjectIdentityFormState = ReturnType<typeof useProjectIdentityForm>;
|
||
|
|
|
||
|
|
type ProjectIdentityFieldsProps = {
|
||
|
|
form: ProjectIdentityFormState;
|
||
|
|
};
|
||
|
|
|
||
|
|
export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ form }) => {
|
||
|
|
const { t } = useI18n();
|
||
|
|
const { currentTheme } = useThemeSystem();
|
||
|
|
const {
|
||
|
|
name,
|
||
|
|
setName,
|
||
|
|
icon,
|
||
|
|
setIcon,
|
||
|
|
color,
|
||
|
|
setColor,
|
||
|
|
iconBackground,
|
||
|
|
setIconBackground,
|
||
|
|
parsedDefaultModel,
|
||
|
|
handleDefaultModelChange,
|
||
|
|
isUploadingIcon,
|
||
|
|
isRemovingCustomIcon,
|
||
|
|
isDiscoveringIcon,
|
||
|
|
pendingRemoveImageIcon,
|
||
|
|
setPendingRemoveImageIcon,
|
||
|
|
pendingUploadIconPreviewUrl,
|
||
|
|
setPreviewImageFailed,
|
||
|
|
hasPendingUploadImageIcon,
|
||
|
|
hasCustomIcon,
|
||
|
|
effectiveHasImageIcon,
|
||
|
|
hasRemovableImageIcon,
|
||
|
|
showImagePreview,
|
||
|
|
fileInputRef,
|
||
|
|
handleUploadIcon,
|
||
|
|
handleRemoveImageIcon,
|
||
|
|
handleDiscoverIcon,
|
||
|
|
currentIconImage,
|
||
|
|
project,
|
||
|
|
} = form;
|
||
|
|
|
||
|
|
if (!project) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const currentColorVar = color ? (COLOR_MAP[color] ?? null) : null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<ProjectSettingsSubsection
|
||
|
|
title={t('settings.projects.page.field.projectName')}
|
||
|
|
settingsItem="projects.name"
|
||
|
|
>
|
||
|
|
<Input
|
||
|
|
value={name}
|
||
|
|
onChange={(event) => setName(event.target.value)}
|
||
|
|
placeholder={t('settings.projects.page.field.projectNamePlaceholder')}
|
||
|
|
className={cn('h-7', PROJECT_SETTINGS_CONTROL_WIDTH)}
|
||
|
|
/>
|
||
|
|
</ProjectSettingsSubsection>
|
||
|
|
|
||
|
|
<ProjectSettingsSubsection
|
||
|
|
title={t('settings.projects.page.field.defaultModel')}
|
||
|
|
description={t('settings.projects.page.field.defaultModelDescription')}
|
||
|
|
settingsItem="projects.default-model"
|
||
|
|
>
|
||
|
|
<ModelSelector
|
||
|
|
providerId={parsedDefaultModel.providerId}
|
||
|
|
modelId={parsedDefaultModel.modelId}
|
||
|
|
onChange={handleDefaultModelChange}
|
||
|
|
className={PROJECT_SETTINGS_CONTROL_WIDTH}
|
||
|
|
/>
|
||
|
|
</ProjectSettingsSubsection>
|
||
|
|
|
||
|
|
<ProjectSettingsSubsection
|
||
|
|
title={t('settings.projects.page.field.accentColor')}
|
||
|
|
settingsItem="projects.accent-color"
|
||
|
|
>
|
||
|
|
<div className="flex flex-wrap items-center gap-2">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => setColor(null)}
|
||
|
|
className={cn(
|
||
|
|
'h-7 w-7 rounded-md border transition-colors flex items-center justify-center',
|
||
|
|
color === null
|
||
|
|
? 'border-2 border-foreground bg-[var(--primary-base)]/10'
|
||
|
|
: 'border-border/40 hover:border-border hover:bg-[var(--surface-muted)]',
|
||
|
|
)}
|
||
|
|
title={t('settings.projects.page.field.none')}
|
||
|
|
>
|
||
|
|
<Icon name="close" className="h-4 w-4 text-muted-foreground" />
|
||
|
|
</button>
|
||
|
|
{PROJECT_COLORS.map((entry) => (
|
||
|
|
<button
|
||
|
|
key={entry.key}
|
||
|
|
type="button"
|
||
|
|
onClick={() => setColor(entry.key)}
|
||
|
|
className={cn(
|
||
|
|
'h-7 w-7 rounded-md border transition-colors',
|
||
|
|
color === entry.key
|
||
|
|
? 'border-2 border-foreground ring-1 ring-[var(--primary-base)]/40'
|
||
|
|
: 'border-transparent hover:border-border/70',
|
||
|
|
)}
|
||
|
|
style={{ backgroundColor: entry.cssVar }}
|
||
|
|
title={entry.label}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</ProjectSettingsSubsection>
|
||
|
|
|
||
|
|
<ProjectSettingsSubsection
|
||
|
|
title={t('settings.projects.page.field.projectIcon')}
|
||
|
|
settingsItem="projects.icon"
|
||
|
|
>
|
||
|
|
<input
|
||
|
|
ref={fileInputRef}
|
||
|
|
type="file"
|
||
|
|
accept="image/png,image/jpeg,image/svg+xml,.png,.jpg,.jpeg,.svg"
|
||
|
|
className="hidden"
|
||
|
|
onChange={(event) => {
|
||
|
|
const file = event.target.files?.[0] ?? null;
|
||
|
|
void handleUploadIcon(file);
|
||
|
|
event.currentTarget.value = '';
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
<div className="flex flex-wrap items-center gap-2">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => setIcon(null)}
|
||
|
|
className={cn(
|
||
|
|
'h-7 w-7 rounded-md border transition-colors flex items-center justify-center',
|
||
|
|
icon === null
|
||
|
|
? 'border-2 border-foreground bg-[var(--primary-base)]/10'
|
||
|
|
: 'border-border/40 hover:border-border hover:bg-[var(--surface-muted)]',
|
||
|
|
)}
|
||
|
|
title={t('settings.projects.page.field.none')}
|
||
|
|
>
|
||
|
|
<Icon name="close" className="h-4 w-4 text-muted-foreground" />
|
||
|
|
</button>
|
||
|
|
{PROJECT_ICONS.map((entry) => {
|
||
|
|
const iconName = entry.Icon;
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
key={entry.key}
|
||
|
|
type="button"
|
||
|
|
onClick={() => setIcon(entry.key)}
|
||
|
|
className={cn(
|
||
|
|
'h-7 w-7 rounded-md border transition-colors flex items-center justify-center',
|
||
|
|
icon === entry.key
|
||
|
|
? 'border-2 border-foreground bg-[var(--primary-base)]/10'
|
||
|
|
: 'border-transparent hover:border-border hover:bg-[var(--surface-muted)]',
|
||
|
|
)}
|
||
|
|
title={entry.label}
|
||
|
|
>
|
||
|
|
<Icon
|
||
|
|
name={iconName}
|
||
|
|
className="w-4 h-4"
|
||
|
|
style={currentColorVar && icon === entry.key ? { color: currentColorVar } : undefined}
|
||
|
|
/>
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
{effectiveHasImageIcon && showImagePreview && (
|
||
|
|
<div className="flex items-center gap-2 pt-1">
|
||
|
|
<span className="typography-meta text-muted-foreground">{t('settings.projects.page.field.preview')}</span>
|
||
|
|
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-border/60 bg-[var(--surface-elevated)] p-1">
|
||
|
|
<span
|
||
|
|
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
|
||
|
|
style={iconBackground ? { backgroundColor: iconBackground } : undefined}
|
||
|
|
>
|
||
|
|
{hasPendingUploadImageIcon && pendingUploadIconPreviewUrl ? (
|
||
|
|
<img
|
||
|
|
src={pendingUploadIconPreviewUrl}
|
||
|
|
alt=""
|
||
|
|
className="h-full w-full object-contain"
|
||
|
|
draggable={false}
|
||
|
|
onError={() => setPreviewImageFailed(true)}
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<ProjectIconImage
|
||
|
|
project={{ ...project, iconImage: currentIconImage ?? project.iconImage }}
|
||
|
|
options={{
|
||
|
|
themeVariant: currentTheme.metadata.variant,
|
||
|
|
iconColor: currentTheme.colors.surface.foreground,
|
||
|
|
}}
|
||
|
|
className="h-full w-full object-contain"
|
||
|
|
onError={() => setPreviewImageFailed(true)}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</span>
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{effectiveHasImageIcon && (
|
||
|
|
<div className="flex flex-wrap items-center gap-2">
|
||
|
|
<input
|
||
|
|
type="color"
|
||
|
|
value={iconBackground ?? '#000000'}
|
||
|
|
onChange={(event) => setIconBackground(event.target.value)}
|
||
|
|
className="h-7 w-9 cursor-pointer rounded border border-border bg-transparent p-1"
|
||
|
|
aria-label={t('settings.projects.page.field.projectIconBackgroundAria')}
|
||
|
|
/>
|
||
|
|
<Input
|
||
|
|
value={iconBackground ?? ''}
|
||
|
|
onChange={(event) => setIconBackground(event.target.value)}
|
||
|
|
placeholder="#000000"
|
||
|
|
className="h-7 w-[8rem]"
|
||
|
|
/>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
size="xs"
|
||
|
|
variant="outline"
|
||
|
|
onClick={() => setIconBackground(null)}
|
||
|
|
className="h-7 w-7 p-0"
|
||
|
|
aria-label={t('settings.projects.page.field.clearIconBackgroundAria')}
|
||
|
|
title={t('settings.projects.page.field.clearBackground')}
|
||
|
|
disabled={!iconBackground}
|
||
|
|
>
|
||
|
|
<Icon name="close" className="h-3.5 w-3.5" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<div className="flex flex-wrap items-center gap-2">
|
||
|
|
{!hasCustomIcon && (
|
||
|
|
<>
|
||
|
|
<Button
|
||
|
|
size="xs"
|
||
|
|
className="h-6 !font-normal"
|
||
|
|
onClick={() => fileInputRef.current?.click()}
|
||
|
|
disabled={isUploadingIcon}
|
||
|
|
>
|
||
|
|
{isUploadingIcon ? t('settings.projects.page.actions.uploading') : t('settings.projects.page.actions.uploadIcon')}
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
size="xs"
|
||
|
|
className="h-6 !font-normal"
|
||
|
|
variant="outline"
|
||
|
|
onClick={() => void handleDiscoverIcon()}
|
||
|
|
disabled={isDiscoveringIcon}
|
||
|
|
>
|
||
|
|
{isDiscoveringIcon ? t('settings.projects.page.actions.discovering') : t('settings.projects.page.actions.discoverFavicon')}
|
||
|
|
</Button>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
{hasRemovableImageIcon && (
|
||
|
|
<Button
|
||
|
|
size="xs"
|
||
|
|
className="!font-normal"
|
||
|
|
variant="outline"
|
||
|
|
onClick={() => void handleRemoveImageIcon()}
|
||
|
|
disabled={isRemovingCustomIcon}
|
||
|
|
>
|
||
|
|
{isRemovingCustomIcon ? t('settings.projects.page.actions.removing') : t('settings.projects.page.actions.removeProjectIcon')}
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
{pendingRemoveImageIcon && (
|
||
|
|
<Button
|
||
|
|
size="xs"
|
||
|
|
className="!font-normal"
|
||
|
|
variant="outline"
|
||
|
|
onClick={() => setPendingRemoveImageIcon(false)}
|
||
|
|
disabled={isRemovingCustomIcon}
|
||
|
|
>
|
||
|
|
{t('settings.projects.page.actions.undoRemove')}
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</ProjectSettingsSubsection>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
};
|