mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-11 17:46:41 -05:00
Better environment color picker (#282)
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { createWorkspaceModel, type Environment } from '@yaakapp-internal/models';
|
||||
import { type Environment } from '@yaakapp-internal/models';
|
||||
import { CreateEnvironmentDialog } from '../components/CreateEnvironmentDialog';
|
||||
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
|
||||
import { createFastMutation } from '../hooks/useFastMutation';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { showPrompt } from '../lib/prompt';
|
||||
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
|
||||
|
||||
export const createSubEnvironmentAndActivate = createFastMutation<
|
||||
@@ -21,24 +22,23 @@ export const createSubEnvironmentAndActivate = createFastMutation<
|
||||
throw new Error('Cannot create environment when no active workspace');
|
||||
}
|
||||
|
||||
const name = await showPrompt({
|
||||
id: 'new-environment',
|
||||
title: 'New Environment',
|
||||
description: 'Create multiple environments with different sets of variables',
|
||||
label: 'Name',
|
||||
placeholder: 'My Environment',
|
||||
defaultValue: 'My Environment',
|
||||
confirmText: 'Create',
|
||||
});
|
||||
if (name == null) return null;
|
||||
|
||||
return createWorkspaceModel({
|
||||
model: 'environment',
|
||||
name,
|
||||
variables: [],
|
||||
workspaceId,
|
||||
parentId: baseEnvironment.id,
|
||||
parentModel: 'environment',
|
||||
return new Promise<string | null>((resolve) => {
|
||||
showDialog({
|
||||
id: 'new-environment',
|
||||
title: 'New Environment',
|
||||
description: 'Create multiple environments with different sets of variables',
|
||||
size: 'sm',
|
||||
onClose: () => resolve(null),
|
||||
render: ({ hide }) => (
|
||||
<CreateEnvironmentDialog
|
||||
workspaceId={workspaceId}
|
||||
hide={hide}
|
||||
onCreate={(id: string) => {
|
||||
resolve(id);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
});
|
||||
},
|
||||
onSuccess: async (environmentId) => {
|
||||
25
src-web/components/ColorIndicator.tsx
Normal file
25
src-web/components/ColorIndicator.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
interface Props {
|
||||
color: string | null;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function ColorIndicator({ color, onClick }: Props) {
|
||||
const style: CSSProperties = { backgroundColor: color ?? undefined };
|
||||
const className =
|
||||
'inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent';
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
className={classNames(className, 'hover:border-text')}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return <span style={style} className={className} />;
|
||||
}
|
||||
}
|
||||
68
src-web/components/CreateEnvironmentDialog.tsx
Normal file
68
src-web/components/CreateEnvironmentDialog.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { createWorkspaceModel } from '@yaakapp-internal/models';
|
||||
import { useState } from 'react';
|
||||
import { useToggle } from '../hooks/useToggle';
|
||||
import { ColorIndicator } from './ColorIndicator';
|
||||
import { Button } from './core/Button';
|
||||
import { Checkbox } from './core/Checkbox';
|
||||
import { ColorPickerWithThemeColors } from './core/ColorPicker';
|
||||
import { Label } from './core/Label';
|
||||
import { PlainInput } from './core/PlainInput';
|
||||
|
||||
interface Props {
|
||||
onCreate: (id: string) => void;
|
||||
hide: () => void;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props) {
|
||||
const [name, setName] = useState<string>('');
|
||||
const [color, setColor] = useState<string | null>(null);
|
||||
const [sharable, toggleSharable] = useToggle(false);
|
||||
return (
|
||||
<form
|
||||
className="pb-3 flex flex-col gap-3"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const id = await createWorkspaceModel({
|
||||
model: 'environment',
|
||||
name,
|
||||
color,
|
||||
variables: [],
|
||||
public: sharable,
|
||||
workspaceId,
|
||||
parentModel: 'environment',
|
||||
});
|
||||
hide();
|
||||
onCreate(id);
|
||||
}}
|
||||
>
|
||||
<PlainInput
|
||||
label="Name"
|
||||
required
|
||||
defaultValue={name}
|
||||
onChange={setName}
|
||||
placeholder="Production"
|
||||
/>
|
||||
<Checkbox
|
||||
checked={sharable}
|
||||
title="Share this environment"
|
||||
help="Sharable environments are included in data export and directory sync."
|
||||
onChange={toggleSharable}
|
||||
/>
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="color"
|
||||
className="mb-1.5"
|
||||
help="Select a color to be displayed when this environment is active, to help identify it."
|
||||
>
|
||||
Color
|
||||
</Label>
|
||||
<ColorPickerWithThemeColors onChange={setColor} color={color} />
|
||||
</div>
|
||||
<Button type="submit" color="secondary" className="mt-3">
|
||||
{color != null && <ColorIndicator color={color} />}
|
||||
Create Environment
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Environment } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { showColorPicker } from '../lib/showColorPicker';
|
||||
import { ColorIndicator } from './ColorIndicator';
|
||||
|
||||
export function EnvironmentColorIndicator({
|
||||
environment,
|
||||
@@ -11,19 +11,10 @@ export function EnvironmentColorIndicator({
|
||||
}) {
|
||||
if (environment?.color == null) return null;
|
||||
|
||||
const style = { backgroundColor: environment.color };
|
||||
const className =
|
||||
'inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent';
|
||||
|
||||
if (clickToEdit) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => showColorPicker(environment)}
|
||||
style={style}
|
||||
className={classNames(className, 'hover:border-text')}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return <span style={style} className={className} />;
|
||||
}
|
||||
return (
|
||||
<ColorIndicator
|
||||
color={environment?.color ?? null}
|
||||
onClick={clickToEdit ? () => showColorPicker(environment) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { ColorIndicator } from './ColorIndicator';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Button } from './core/Button';
|
||||
import { ColorPicker } from './core/ColorPicker';
|
||||
import { ColorPickerWithThemeColors } from './core/ColorPicker';
|
||||
|
||||
export function EnvironmentColorPicker({
|
||||
color: defaultColor,
|
||||
@@ -12,21 +14,20 @@ export function EnvironmentColorPicker({
|
||||
const [color, setColor] = useState<string | null>(defaultColor);
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col items-stretch gap-3 pb-2 w-full"
|
||||
className="flex flex-col items-stretch gap-5 pb-2 w-full"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onChange(color);
|
||||
}}
|
||||
>
|
||||
<ColorPicker color={color} onChange={setColor} />
|
||||
<div className="grid grid-cols-[1fr_1fr] gap-1.5">
|
||||
<Button variant="border" color="secondary" onClick={() => onChange(null)}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button type="submit" color="primary">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<Banner color="secondary">
|
||||
This color will be used to color the interface when this environment is active
|
||||
</Banner>
|
||||
<ColorPickerWithThemeColors color={color} onChange={setColor} />
|
||||
<Button type="submit" color="secondary">
|
||||
{color != null && <ColorIndicator color={color} />}
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -232,17 +232,14 @@ function EnvironmentDialogSidebarButton({
|
||||
await patchModel(environment, { name });
|
||||
},
|
||||
},
|
||||
...((duplicateEnvironment
|
||||
? [
|
||||
{
|
||||
label: 'Duplicate',
|
||||
leftSlot: <Icon icon="copy" />,
|
||||
onSelect: () => {
|
||||
duplicateEnvironment?.(environment);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []) as DropdownItem[]),
|
||||
{
|
||||
label: 'Duplicate',
|
||||
leftSlot: <Icon icon="copy" />,
|
||||
hidden: isBaseEnvironment(environment),
|
||||
onSelect: () => {
|
||||
duplicateEnvironment?.(environment);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: environment.color ? 'Change Color' : 'Assign Color',
|
||||
leftSlot: <Icon icon="palette" />,
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import classNames from 'classnames';
|
||||
import { useState } from 'react';
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
import { useRandomKey } from '../../hooks/useRandomKey';
|
||||
import { Icon } from './Icon';
|
||||
import { PlainInput } from './PlainInput';
|
||||
|
||||
interface Props {
|
||||
onChange: (value: string | null) => void;
|
||||
color: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ColorPicker({ onChange, color }: Props) {
|
||||
export function ColorPicker({ onChange, color, className }: Props) {
|
||||
const [updateKey, regenerateKey] = useRandomKey();
|
||||
return (
|
||||
<div>
|
||||
<div className={className}>
|
||||
<HexColorPicker
|
||||
color={color ?? undefined}
|
||||
className="!w-full"
|
||||
@@ -30,3 +34,84 @@ export function ColorPicker({ onChange, color }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const colors = [
|
||||
null,
|
||||
'danger',
|
||||
'warning',
|
||||
'notice',
|
||||
'success',
|
||||
'primary',
|
||||
'info',
|
||||
'secondary',
|
||||
'custom',
|
||||
] as const;
|
||||
|
||||
export function ColorPickerWithThemeColors({ onChange, color, className }: Props) {
|
||||
const [updateKey, regenerateKey] = useRandomKey();
|
||||
const [selectedColor, setSelectedColor] = useState<string | null>(() => {
|
||||
if (color == null) return null;
|
||||
const c = color?.match(/var\(--([a-z]+)\)/)?.[1];
|
||||
return c ?? 'custom';
|
||||
});
|
||||
return (
|
||||
<div className={classNames(className, 'flex flex-col gap-3')}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{colors.map((color) => (
|
||||
<button
|
||||
type="button"
|
||||
key={color}
|
||||
onClick={() => {
|
||||
setSelectedColor(color);
|
||||
if (color == null) {
|
||||
onChange(null);
|
||||
} else if (color === 'custom') {
|
||||
onChange('#ffffff');
|
||||
} else {
|
||||
onChange(`var(--${color})`);
|
||||
}
|
||||
}}
|
||||
className={classNames(
|
||||
'flex items-center justify-center',
|
||||
'w-8 h-8 rounded-full transition-all',
|
||||
selectedColor === color && 'scale-[1.15]',
|
||||
selectedColor === color ? 'opacity-100' : 'opacity-60',
|
||||
color === null && 'border border-text-subtle',
|
||||
color === 'primary' && 'bg-primary',
|
||||
color === 'secondary' && 'bg-secondary',
|
||||
color === 'success' && 'bg-success',
|
||||
color === 'notice' && 'bg-notice',
|
||||
color === 'warning' && 'bg-warning',
|
||||
color === 'danger' && 'bg-danger',
|
||||
color === 'info' && 'bg-info',
|
||||
color === 'custom' &&
|
||||
'bg-[conic-gradient(var(--danger),var(--warning),var(--notice),var(--success),var(--info),var(--primary),var(--danger))]',
|
||||
)}
|
||||
>
|
||||
{color == null && <Icon icon="minus" className="text-text-subtle" size="md" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedColor === 'custom' && (
|
||||
<>
|
||||
<HexColorPicker
|
||||
color={color ?? undefined}
|
||||
className="!w-full"
|
||||
onChange={(color) => {
|
||||
onChange(color);
|
||||
regenerateKey(); // To force input to change
|
||||
}}
|
||||
/>
|
||||
<PlainInput
|
||||
hideLabel
|
||||
label="Plain Color"
|
||||
forceUpdateKey={updateKey}
|
||||
defaultValue={color ?? ''}
|
||||
onChange={onChange}
|
||||
validate={(color) => color.match(/#[0-9a-fA-F]{6}$/) !== null}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
CircleDollarSignIcon,
|
||||
CircleFadingArrowUpIcon,
|
||||
CircleHelpIcon,
|
||||
CircleOffIcon,
|
||||
ClipboardPasteIcon,
|
||||
ClockIcon,
|
||||
CodeIcon,
|
||||
@@ -191,6 +192,7 @@ const icons = {
|
||||
git_fork: GitForkIcon,
|
||||
git_pull_request: GitPullRequestIcon,
|
||||
grip_vertical: GripVerticalIcon,
|
||||
circle_off: CircleOffIcon,
|
||||
hand: HandIcon,
|
||||
help: CircleHelpIcon,
|
||||
history: HistoryIcon,
|
||||
|
||||
@@ -49,7 +49,7 @@ export function getCharsetFromContentType(headers: HttpResponseHeader[]): string
|
||||
}
|
||||
|
||||
export function isBaseEnvironment(environment: Environment): boolean {
|
||||
return environment.parentId == null;
|
||||
return environment.parentModel == 'workspace';
|
||||
}
|
||||
|
||||
export function isSubEnvironment(environment: Environment): boolean {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { Environment } from '@yaakapp-internal/models';
|
||||
import { patchModel } from '@yaakapp-internal/models';
|
||||
import { showDialog } from './dialog';
|
||||
import { EnvironmentColorPicker } from '../components/EnvironmentColorPicker';
|
||||
import { showDialog } from './dialog';
|
||||
|
||||
export function showColorPicker(environment: Environment) {
|
||||
showDialog({
|
||||
title: 'Environment Color',
|
||||
id: 'color-picker',
|
||||
size: 'dynamic',
|
||||
size: 'sm',
|
||||
render: ({ hide }) => {
|
||||
return (
|
||||
<EnvironmentColorPicker
|
||||
color={environment.color ?? '#54dc44'}
|
||||
color={environment.color}
|
||||
onChange={async (color) => {
|
||||
await patchModel(environment, { color });
|
||||
hide();
|
||||
|
||||
Reference in New Issue
Block a user