mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-23 17:28:29 +02: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 { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
|
||||||
import { createFastMutation } from '../hooks/useFastMutation';
|
import { createFastMutation } from '../hooks/useFastMutation';
|
||||||
|
import { showDialog } from '../lib/dialog';
|
||||||
import { jotaiStore } from '../lib/jotai';
|
import { jotaiStore } from '../lib/jotai';
|
||||||
import { showPrompt } from '../lib/prompt';
|
|
||||||
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
|
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
|
||||||
|
|
||||||
export const createSubEnvironmentAndActivate = createFastMutation<
|
export const createSubEnvironmentAndActivate = createFastMutation<
|
||||||
@@ -21,24 +22,23 @@ export const createSubEnvironmentAndActivate = createFastMutation<
|
|||||||
throw new Error('Cannot create environment when no active workspace');
|
throw new Error('Cannot create environment when no active workspace');
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = await showPrompt({
|
return new Promise<string | null>((resolve) => {
|
||||||
id: 'new-environment',
|
showDialog({
|
||||||
title: 'New Environment',
|
id: 'new-environment',
|
||||||
description: 'Create multiple environments with different sets of variables',
|
title: 'New Environment',
|
||||||
label: 'Name',
|
description: 'Create multiple environments with different sets of variables',
|
||||||
placeholder: 'My Environment',
|
size: 'sm',
|
||||||
defaultValue: 'My Environment',
|
onClose: () => resolve(null),
|
||||||
confirmText: 'Create',
|
render: ({ hide }) => (
|
||||||
});
|
<CreateEnvironmentDialog
|
||||||
if (name == null) return null;
|
workspaceId={workspaceId}
|
||||||
|
hide={hide}
|
||||||
return createWorkspaceModel({
|
onCreate={(id: string) => {
|
||||||
model: 'environment',
|
resolve(id);
|
||||||
name,
|
}}
|
||||||
variables: [],
|
/>
|
||||||
workspaceId,
|
),
|
||||||
parentId: baseEnvironment.id,
|
});
|
||||||
parentModel: 'environment',
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: async (environmentId) => {
|
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 type { Environment } from '@yaakapp-internal/models';
|
||||||
import classNames from 'classnames';
|
|
||||||
import { showColorPicker } from '../lib/showColorPicker';
|
import { showColorPicker } from '../lib/showColorPicker';
|
||||||
|
import { ColorIndicator } from './ColorIndicator';
|
||||||
|
|
||||||
export function EnvironmentColorIndicator({
|
export function EnvironmentColorIndicator({
|
||||||
environment,
|
environment,
|
||||||
@@ -11,19 +11,10 @@ export function EnvironmentColorIndicator({
|
|||||||
}) {
|
}) {
|
||||||
if (environment?.color == null) return null;
|
if (environment?.color == null) return null;
|
||||||
|
|
||||||
const style = { backgroundColor: environment.color };
|
return (
|
||||||
const className =
|
<ColorIndicator
|
||||||
'inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent';
|
color={environment?.color ?? null}
|
||||||
|
onClick={clickToEdit ? () => showColorPicker(environment) : undefined}
|
||||||
if (clickToEdit) {
|
/>
|
||||||
return (
|
);
|
||||||
<button
|
|
||||||
onClick={() => showColorPicker(environment)}
|
|
||||||
style={style}
|
|
||||||
className={classNames(className, 'hover:border-text')}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return <span style={style} className={className} />;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { ColorIndicator } from './ColorIndicator';
|
||||||
|
import { Banner } from './core/Banner';
|
||||||
import { Button } from './core/Button';
|
import { Button } from './core/Button';
|
||||||
import { ColorPicker } from './core/ColorPicker';
|
import { ColorPickerWithThemeColors } from './core/ColorPicker';
|
||||||
|
|
||||||
export function EnvironmentColorPicker({
|
export function EnvironmentColorPicker({
|
||||||
color: defaultColor,
|
color: defaultColor,
|
||||||
@@ -12,21 +14,20 @@ export function EnvironmentColorPicker({
|
|||||||
const [color, setColor] = useState<string | null>(defaultColor);
|
const [color, setColor] = useState<string | null>(defaultColor);
|
||||||
return (
|
return (
|
||||||
<form
|
<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) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onChange(color);
|
onChange(color);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ColorPicker color={color} onChange={setColor} />
|
<Banner color="secondary">
|
||||||
<div className="grid grid-cols-[1fr_1fr] gap-1.5">
|
This color will be used to color the interface when this environment is active
|
||||||
<Button variant="border" color="secondary" onClick={() => onChange(null)}>
|
</Banner>
|
||||||
Clear
|
<ColorPickerWithThemeColors color={color} onChange={setColor} />
|
||||||
</Button>
|
<Button type="submit" color="secondary">
|
||||||
<Button type="submit" color="primary">
|
{color != null && <ColorIndicator color={color} />}
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,17 +232,14 @@ function EnvironmentDialogSidebarButton({
|
|||||||
await patchModel(environment, { name });
|
await patchModel(environment, { name });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...((duplicateEnvironment
|
{
|
||||||
? [
|
label: 'Duplicate',
|
||||||
{
|
leftSlot: <Icon icon="copy" />,
|
||||||
label: 'Duplicate',
|
hidden: isBaseEnvironment(environment),
|
||||||
leftSlot: <Icon icon="copy" />,
|
onSelect: () => {
|
||||||
onSelect: () => {
|
duplicateEnvironment?.(environment);
|
||||||
duplicateEnvironment?.(environment);
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
]
|
|
||||||
: []) as DropdownItem[]),
|
|
||||||
{
|
{
|
||||||
label: environment.color ? 'Change Color' : 'Assign Color',
|
label: environment.color ? 'Change Color' : 'Assign Color',
|
||||||
leftSlot: <Icon icon="palette" />,
|
leftSlot: <Icon icon="palette" />,
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
import { useState } from 'react';
|
||||||
import { HexColorPicker } from 'react-colorful';
|
import { HexColorPicker } from 'react-colorful';
|
||||||
import { useRandomKey } from '../../hooks/useRandomKey';
|
import { useRandomKey } from '../../hooks/useRandomKey';
|
||||||
|
import { Icon } from './Icon';
|
||||||
import { PlainInput } from './PlainInput';
|
import { PlainInput } from './PlainInput';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onChange: (value: string | null) => void;
|
onChange: (value: string | null) => void;
|
||||||
color: string | null;
|
color: string | null;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ColorPicker({ onChange, color }: Props) {
|
export function ColorPicker({ onChange, color, className }: Props) {
|
||||||
const [updateKey, regenerateKey] = useRandomKey();
|
const [updateKey, regenerateKey] = useRandomKey();
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={className}>
|
||||||
<HexColorPicker
|
<HexColorPicker
|
||||||
color={color ?? undefined}
|
color={color ?? undefined}
|
||||||
className="!w-full"
|
className="!w-full"
|
||||||
@@ -30,3 +34,84 @@ export function ColorPicker({ onChange, color }: Props) {
|
|||||||
</div>
|
</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,
|
CircleDollarSignIcon,
|
||||||
CircleFadingArrowUpIcon,
|
CircleFadingArrowUpIcon,
|
||||||
CircleHelpIcon,
|
CircleHelpIcon,
|
||||||
|
CircleOffIcon,
|
||||||
ClipboardPasteIcon,
|
ClipboardPasteIcon,
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
CodeIcon,
|
CodeIcon,
|
||||||
@@ -191,6 +192,7 @@ const icons = {
|
|||||||
git_fork: GitForkIcon,
|
git_fork: GitForkIcon,
|
||||||
git_pull_request: GitPullRequestIcon,
|
git_pull_request: GitPullRequestIcon,
|
||||||
grip_vertical: GripVerticalIcon,
|
grip_vertical: GripVerticalIcon,
|
||||||
|
circle_off: CircleOffIcon,
|
||||||
hand: HandIcon,
|
hand: HandIcon,
|
||||||
help: CircleHelpIcon,
|
help: CircleHelpIcon,
|
||||||
history: HistoryIcon,
|
history: HistoryIcon,
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function getCharsetFromContentType(headers: HttpResponseHeader[]): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isBaseEnvironment(environment: Environment): boolean {
|
export function isBaseEnvironment(environment: Environment): boolean {
|
||||||
return environment.parentId == null;
|
return environment.parentModel == 'workspace';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSubEnvironment(environment: Environment): boolean {
|
export function isSubEnvironment(environment: Environment): boolean {
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import type { Environment } from '@yaakapp-internal/models';
|
import type { Environment } from '@yaakapp-internal/models';
|
||||||
import { patchModel } from '@yaakapp-internal/models';
|
import { patchModel } from '@yaakapp-internal/models';
|
||||||
import { showDialog } from './dialog';
|
|
||||||
import { EnvironmentColorPicker } from '../components/EnvironmentColorPicker';
|
import { EnvironmentColorPicker } from '../components/EnvironmentColorPicker';
|
||||||
|
import { showDialog } from './dialog';
|
||||||
|
|
||||||
export function showColorPicker(environment: Environment) {
|
export function showColorPicker(environment: Environment) {
|
||||||
showDialog({
|
showDialog({
|
||||||
title: 'Environment Color',
|
title: 'Environment Color',
|
||||||
id: 'color-picker',
|
id: 'color-picker',
|
||||||
size: 'dynamic',
|
size: 'sm',
|
||||||
render: ({ hide }) => {
|
render: ({ hide }) => {
|
||||||
return (
|
return (
|
||||||
<EnvironmentColorPicker
|
<EnvironmentColorPicker
|
||||||
color={environment.color ?? '#54dc44'}
|
color={environment.color}
|
||||||
onChange={async (color) => {
|
onChange={async (color) => {
|
||||||
await patchModel(environment, { color });
|
await patchModel(environment, { color });
|
||||||
hide();
|
hide();
|
||||||
|
|||||||
Reference in New Issue
Block a user