Browse Source

WIP config update

pull/82/head
Sacha Weatherstone 3 years ago
parent
commit
e3f2a4585f
  1. 4
      .trunk/trunk.yaml
  2. 2
      src/PageRouter.tsx
  3. 11
      src/components/DeviceSelector.tsx
  4. 10
      src/components/NewDevice.tsx
  5. 12
      src/components/PageComponents/AppConfig/Map.tsx
  6. 26
      src/components/PageComponents/Channel.tsx
  7. 4
      src/components/PageComponents/Config/Bluetooth.tsx
  8. 4
      src/components/PageComponents/Config/Device.tsx
  9. 78
      src/components/PageComponents/Config/Display.tsx
  10. 4
      src/components/PageComponents/Config/LoRa.tsx
  11. 4
      src/components/PageComponents/Config/Network.tsx
  12. 4
      src/components/PageComponents/Config/Position.tsx
  13. 4
      src/components/PageComponents/Config/Power.tsx
  14. 10
      src/components/PageComponents/Config/User.tsx
  15. 8
      src/components/PageComponents/ModuleConfig/Audio.tsx
  16. 8
      src/components/PageComponents/ModuleConfig/CannedMessage.tsx
  17. 8
      src/components/PageComponents/ModuleConfig/ExternalNotification.tsx
  18. 8
      src/components/PageComponents/ModuleConfig/MQTT.tsx
  19. 4
      src/components/PageComponents/ModuleConfig/RangeTest.tsx
  20. 8
      src/components/PageComponents/ModuleConfig/Serial.tsx
  21. 8
      src/components/PageComponents/ModuleConfig/StoreForward.tsx
  22. 8
      src/components/PageComponents/ModuleConfig/Telemetry.tsx
  23. 5
      src/components/Widgets/DeviceWidget.tsx
  24. 2
      src/components/form/Button.tsx
  25. 63
      src/components/form/Form.tsx
  26. 2
      src/components/generic/TabbedContent.tsx
  27. 77
      src/pages/Config/DeviceConfig.tsx

4
.trunk/trunk.yaml

@ -13,8 +13,8 @@ lint:
- [email protected] - [email protected]
- [email protected] - [email protected]
- git-diff-check - git-diff-check
- [email protected]2 - [email protected]3
- [email protected].2 - [email protected].3
runtimes: runtimes:
enabled: enabled:
- [email protected] - [email protected]

2
src/PageRouter.tsx

@ -11,7 +11,7 @@ import { PeersPage } from "@pages/Peers.js";
export const PageRouter = (): JSX.Element => { export const PageRouter = (): JSX.Element => {
const { activePage } = useDevice(); const { activePage } = useDevice();
return ( return (
<div className="flex-grow overflow-y-auto border-l-2 border-backgroundPrimary"> <div className="flex-grow overflow-y-auto bg-backgroundPrimary">
{activePage === "messages" && <MessagesPage />} {activePage === "messages" && <MessagesPage />}
{activePage === "map" && <MapPage />} {activePage === "map" && <MapPage />}
{activePage === "extensions" && <ExtensionsPage />} {activePage === "extensions" && <ExtensionsPage />}

11
src/components/DeviceSelector.tsx

@ -7,15 +7,15 @@ import { PageNav } from "@app/Nav/PageNav.js";
import { Mono } from "@components/generic/Mono.js"; import { Mono } from "@components/generic/Mono.js";
import { Hashicon } from "@emeraldpay/hashicon-react"; import { Hashicon } from "@emeraldpay/hashicon-react";
import { PlusIcon } from "@heroicons/react/24/outline"; import { PlusIcon } from "@heroicons/react/24/outline";
import { MoonIcon, SunIcon } from "@primer/octicons-react";
export const DeviceSelector = (): JSX.Element => { export const DeviceSelector = (): JSX.Element => {
const { getDevices } = useDeviceStore(); const { getDevices } = useDeviceStore();
const { selectedDevice, setSelectedDevice, darkMode } = useAppStore(); const { selectedDevice, setSelectedDevice, darkMode, setDarkMode } = useAppStore();
return ( return (
<div className="flex h-full w-14 items-center gap-3 bg-backgroundPrimary pt-3 [writing-mode:vertical-rl]"> <div className="flex h-full w-14 items-center gap-3 bg-backgroundPrimary pt-3 [writing-mode:vertical-rl]">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Mono className="select-none">Connected Devices</Mono>
<span className="flex font-bold text-textPrimary"> <span className="flex font-bold text-textPrimary">
{getDevices().map((device) => ( {getDevices().map((device) => (
<div <div
@ -55,7 +55,12 @@ export const DeviceSelector = (): JSX.Element => {
<NavSpacer /> <NavSpacer />
<div>//actions</div> <div onClick={() => setDarkMode(!darkMode)} className="bg-backgroundPrimary py-5 px-4 hover:brightness-hover active:brightness-press text-textSecondary hover:text-textPrimary">{
darkMode ? (
<SunIcon className="w-4" />
) : (
<MoonIcon className="w-4" />
)}</div>
<img <img
src={darkMode ? "Logo_White.svg" : "Logo_Black.svg"} src={darkMode ? "Logo_White.svg" : "Logo_Black.svg"}

10
src/components/NewDevice.tsx

@ -39,16 +39,6 @@ export const NewDevice = () => {
<div className="m-auto h-96 w-96"> <div className="m-auto h-96 w-96">
<TabbedContent <TabbedContent
tabs={tabs} tabs={tabs}
actions={[
{
icon: darkMode ? (
<SunIcon className="w-4" />
) : (
<MoonIcon className="w-4" />
),
action: () => setDarkMode(!darkMode)
}
]}
/> />
</div> </div>
); );

12
src/components/PageComponents/AppConfig/Map.tsx

@ -49,17 +49,7 @@ export const Map = (): JSX.Element => {
// }, [reset, rasterSources]); // }, [reset, rasterSources]);
return ( return (
<Form <Form onSubmit={onSubmit}>
title="Map Config"
breadcrumbs={["App Config", "Map"]}
reset={() =>
reset({
rasterSources
})
}
dirty={isDirty}
onSubmit={onSubmit}
>
<InfoWrapper label="WMS Sources"> <InfoWrapper label="WMS Sources">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{fields.map((field, index) => ( {fields.map((field, index) => (

26
src/components/PageComponents/Channel.tsx

@ -105,31 +105,7 @@ export const Channel = ({ channel }: SettingsPanelProps): JSX.Element => {
}); });
return ( return (
<Form <Form onSubmit={onSubmit}>
title="Channel Editor"
breadcrumbs={[
"Channels",
channel.settings?.name.length
? channel.settings.name
: channel.role === Protobuf.Channel_Role.PRIMARY
? "Primary"
: `Channel: ${channel.index}`
]}
reset={() =>
reset({
enabled: [
Protobuf.Channel_Role.SECONDARY,
Protobuf.Channel_Role.PRIMARY
].find((role) => role === channel?.role)
? true
: false,
...channel?.settings,
psk: fromByteArray(channel?.settings?.psk ?? new Uint8Array(0))
})
}
dirty={isDirty}
onSubmit={onSubmit}
>
{channel?.index !== 0 && ( {channel?.index !== 0 && (
<> <>
<Controller <Controller

4
src/components/PageComponents/Config/Bluetooth.tsx

@ -71,10 +71,6 @@ export const Bluetooth = (): JSX.Element => {
return ( return (
<Form <Form
title="Bluetooth Config"
breadcrumbs={["Config", "Bluetooth"]}
reset={() => reset(config.bluetooth)}
dirty={isDirty}
onSubmit={onSubmit} onSubmit={onSubmit}
> >
<Controller <Controller

4
src/components/PageComponents/Config/Device.tsx

@ -64,10 +64,6 @@ export const Device = (): JSX.Element => {
return ( return (
<Form <Form
title="Device Config"
breadcrumbs={["Config", "Device"]}
reset={() => reset(config.device)}
dirty={isDirty}
onSubmit={onSubmit} onSubmit={onSubmit}
> >
<Select <Select

78
src/components/PageComponents/Config/Display.tsx

@ -7,7 +7,7 @@ import { toast } from "react-hot-toast";
import { Input } from "@app/components/form/Input.js"; import { Input } from "@app/components/form/Input.js";
import { Select } from "@app/components/form/Select.js"; import { Select } from "@app/components/form/Select.js";
import { Toggle } from "@app/components/form/Toggle.js"; import { Toggle } from "@app/components/form/Toggle.js";
import { DisplayValidation } from "@app/validation/config/display.js"; import type { DisplayValidation } from "@app/validation/config/display.js";
import { Form } from "@components/form/Form"; import { Form } from "@components/form/Form";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/providers/useDevice.js";
import { renderOptions } from "@core/utils/selectEnumOptions.js"; import { renderOptions } from "@core/utils/selectEnumOptions.js";
@ -22,54 +22,48 @@ export const Display = (): JSX.Element => {
formState: { errors, isDirty }, formState: { errors, isDirty },
reset, reset,
control control
} = useForm<Protobuf.Config_DisplayConfig>({ } = useForm<DisplayValidation>({
defaultValues: config.display, defaultValues: config.display
resolver: classValidatorResolver(DisplayValidation) // resolver: classValidatorResolver(DisplayValidation)
}); });
useEffect(() => { // useEffect(() => {
reset(config.display); // reset(config.display);
}, [reset, config.display]); // }, [reset, config.display]);
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
if (connection) { // if (connection) {
void toast.promise( // void toast.promise(
connection // connection
.setConfig( // .setConfig(
new Protobuf.Config({ // new Protobuf.Config({
payloadVariant: { // payloadVariant: {
case: "display", // case: "display",
value: data // value: data
} // }
}) // })
) // )
.then(() => // .then(() =>
setConfig( // setConfig(
new Protobuf.Config({ // new Protobuf.Config({
payloadVariant: { // payloadVariant: {
case: "display", // case: "display",
value: data // value: data
} // }
}) // })
) // )
), // ),
{ // {
loading: "Saving...", // loading: "Saving...",
success: "Saved Display Config, Restarting Node", // success: "Saved Display Config, Restarting Node",
error: "No response received" // error: "No response received"
} // }
); // );
} // }
}); });
return ( return (
<Form <Form onSubmit={onSubmit}>
title="Display Config"
breadcrumbs={["Config", "Display"]}
reset={() => reset(config.display)}
dirty={isDirty}
onSubmit={onSubmit}
>
<Input <Input
label="Screen Timeout" label="Screen Timeout"
description="Turn off the display after this long" description="Turn off the display after this long"

4
src/components/PageComponents/Config/LoRa.tsx

@ -72,10 +72,6 @@ export const LoRa = (): JSX.Element => {
return ( return (
<Form <Form
title="LoRa Config"
breadcrumbs={["Config", "LoRa"]}
reset={() => reset(config.lora)}
dirty={isDirty}
onSubmit={onSubmit} onSubmit={onSubmit}
> >
<FormSection title="Modem Settings"> <FormSection title="Modem Settings">

4
src/components/PageComponents/Config/Network.tsx

@ -85,10 +85,6 @@ export const Network = (): JSX.Element => {
return ( return (
<Form <Form
title="Network Config"
breadcrumbs={["Config", "Network"]}
reset={() => reset(config.network)}
dirty={isDirty}
onSubmit={onSubmit} onSubmit={onSubmit}
> >
<ErrorMessage errors={errors} name="wifiEnabled" /> <ErrorMessage errors={errors} name="wifiEnabled" />

4
src/components/PageComponents/Config/Position.tsx

@ -108,10 +108,6 @@ export const Position = (): JSX.Element => {
return ( return (
<Form <Form
title="Position Config"
breadcrumbs={["Config", "Position"]}
reset={() => reset(config.position)}
dirty={isDirty}
onSubmit={onSubmit} onSubmit={onSubmit}
> >
<Controller <Controller

4
src/components/PageComponents/Config/Power.tsx

@ -63,10 +63,6 @@ export const Power = (): JSX.Element => {
return ( return (
<Form <Form
title="Power Config"
breadcrumbs={["Config", "Power"]}
reset={() => reset(config.power)}
dirty={isDirty}
onSubmit={onSubmit} onSubmit={onSubmit}
> >
<Input <Input

10
src/components/PageComponents/Config/User.tsx

@ -62,16 +62,6 @@ export const User = (): JSX.Element => {
return ( return (
<Form <Form
title="User Config"
breadcrumbs={["Config", "User"]}
reset={() => {
reset({
longName: myNode?.data.user?.longName,
shortName: myNode?.data.user?.shortName,
isLicensed: myNode?.data.user?.isLicensed
});
}}
dirty={isDirty}
onSubmit={onSubmit} onSubmit={onSubmit}
> >
<ErrorMessage errors={errors} name="longName" /> <ErrorMessage errors={errors} name="longName" />

8
src/components/PageComponents/ModuleConfig/Audio.tsx

@ -63,13 +63,7 @@ export const Audio = (): JSX.Element => {
}); });
return ( return (
<Form <Form onSubmit={onSubmit}>
title="Audio Config"
breadcrumbs={["Module Config", "Audio"]}
reset={() => reset(moduleConfig.audio)}
dirty={isDirty}
onSubmit={onSubmit}
>
<Controller <Controller
name="codec2Enabled" name="codec2Enabled"
control={control} control={control}

8
src/components/PageComponents/ModuleConfig/CannedMessage.tsx

@ -69,13 +69,7 @@ export const CannedMessage = (): JSX.Element => {
}); });
return ( return (
<Form <Form onSubmit={onSubmit}>
title="Canned Message Config"
breadcrumbs={["Module Config", "Canned Message"]}
reset={() => reset(moduleConfig.cannedMessage)}
dirty={isDirty}
onSubmit={onSubmit}
>
<Controller <Controller
name="enabled" name="enabled"
control={control} control={control}

8
src/components/PageComponents/ModuleConfig/ExternalNotification.tsx

@ -66,13 +66,7 @@ export const ExternalNotification = (): JSX.Element => {
}); });
return ( return (
<Form <Form onSubmit={onSubmit}>
title="External Notification Config"
breadcrumbs={["Module Config", "External Notification"]}
reset={() => reset(moduleConfig.externalNotification)}
dirty={isDirty}
onSubmit={onSubmit}
>
<Controller <Controller
name="enabled" name="enabled"
control={control} control={control}

8
src/components/PageComponents/ModuleConfig/MQTT.tsx

@ -67,13 +67,7 @@ export const MQTT = (): JSX.Element => {
}); });
return ( return (
<Form <Form onSubmit={onSubmit}>
title="MQTT Config"
breadcrumbs={["Module Config", "MQTT"]}
reset={() => reset(moduleConfig.mqtt)}
dirty={isDirty}
onSubmit={onSubmit}
>
<Controller <Controller
name="enabled" name="enabled"
control={control} control={control}

4
src/components/PageComponents/ModuleConfig/RangeTest.tsx

@ -68,10 +68,6 @@ export const RangeTest = (): JSX.Element => {
return ( return (
<Form <Form
title="Range Test Config"
breadcrumbs={["Module Config", "Range Test"]}
reset={() => reset(moduleConfig.rangeTest)}
dirty={isDirty}
onSubmit={onSubmit} onSubmit={onSubmit}
> >
<Controller <Controller

8
src/components/PageComponents/ModuleConfig/Serial.tsx

@ -69,13 +69,7 @@ export const Serial = (): JSX.Element => {
}); });
return ( return (
<Form <Form onSubmit={onSubmit}>
title="Serial Config"
breadcrumbs={["Module Config", "Serial"]}
reset={() => reset(moduleConfig.serial)}
dirty={isDirty}
onSubmit={onSubmit}
>
<Controller <Controller
name="enabled" name="enabled"
control={control} control={control}

8
src/components/PageComponents/ModuleConfig/StoreForward.tsx

@ -67,13 +67,7 @@ export const StoreForward = (): JSX.Element => {
}); });
return ( return (
<Form <Form onSubmit={onSubmit}>
title="Store & Forward Config"
breadcrumbs={["Module Config", "Store & Forward"]}
reset={() => reset(moduleConfig.storeForward)}
dirty={isDirty}
onSubmit={onSubmit}
>
<Controller <Controller
name="enabled" name="enabled"
control={control} control={control}

8
src/components/PageComponents/ModuleConfig/Telemetry.tsx

@ -61,13 +61,7 @@ export const Telemetry = (): JSX.Element => {
}); });
return ( return (
<Form <Form onSubmit={onSubmit}>
title="Telemetry Config"
breadcrumbs={["Module Config", "Telemetry"]}
reset={() => reset(moduleConfig.telemetry)}
dirty={isDirty}
onSubmit={onSubmit}
>
<Controller <Controller
name="environmentMeasurementEnabled" name="environmentMeasurementEnabled"
control={control} control={control}

5
src/components/Widgets/DeviceWidget.tsx

@ -21,10 +21,7 @@ export const DeviceWidget = ({
}: DeviceWidgetProps): JSX.Element => { }: DeviceWidgetProps): JSX.Element => {
return ( return (
<div className="relative flex shrink-0 flex-col overflow-hidden rounded-md text-sm text-textPrimary"> <div className="relative flex shrink-0 flex-col overflow-hidden rounded-md text-sm text-textPrimary">
<div className="absolute bottom-20 h-full w-full"> <div className="bg-backgroundPrimary flex p-3">
<Hashicon size={350} value={nodeNum} />
</div>
<div className="backdrop-brightness-50 flex p-3 backdrop-blur-md backdrop-hue-rotate-30">
<div> <div>
<Hashicon size={96} value={nodeNum} /> <Hashicon size={96} value={nodeNum} />
</div> </div>

2
src/components/form/Button.tsx

@ -16,7 +16,7 @@ export const Button = ({
}: ButtonProps): JSX.Element => { }: ButtonProps): JSX.Element => {
return ( return (
<button <button
className={`flex w-full rounded-md bg-accentMuted px-3 text-textPrimary hover:brightness-hover focus:outline-none active:brightness-press ${ className={`flex w-full select-none rounded-md bg-accentMuted px-3 text-textPrimary hover:brightness-hover focus:outline-none active:brightness-press ${
size === "sm" size === "sm"
? "h-8 text-sm" ? "h-8 text-sm"
: size === "md" : size === "md"

63
src/components/form/Form.tsx

@ -1,73 +1,22 @@
import type React from "react"; import type React from "react";
import type { HTMLProps } from "react"; import type { HTMLProps } from "react";
import { Button } from "@components/form/Button.js";
import {
ArrowRightCircleIcon,
ArrowUturnLeftIcon,
CheckIcon,
ChevronRightIcon,
HomeIcon
} from "@heroicons/react/24/outline";
export interface FormProps extends HTMLProps<HTMLFormElement> { export interface FormProps extends HTMLProps<HTMLFormElement> {
title: string;
breadcrumbs: string[];
reset: () => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => Promise<void>; onSubmit: (event: React.FormEvent<HTMLFormElement>) => Promise<void>;
dirty: boolean;
} }
export const Form = ({ export const Form = ({
title,
breadcrumbs,
reset,
dirty,
children, children,
onSubmit, onSubmit,
...props ...props
}: FormProps): JSX.Element => { }: FormProps): JSX.Element => {
return ( return (
// eslint-disable-next-line @typescript-eslint/no-misused-promises <form
<form className="w-full px-2" onSubmit={onSubmit} {...props}> className="mr-2 w-full rounded-md bg-backgroundSecondary px-2"
<div className="select-none rounded-md bg-backgroundPrimary p-4"> onSubmit={onSubmit}
<ol className="flex gap-4 text-textSecondary"> {...props}
<li className="cursor-pointer hover:brightness-disabled"> >
<HomeIcon className="h-5 w-5 flex-shrink-0" /> <div className="flex flex-col gap-3 p-4">{children}</div>
</li>
{breadcrumbs.map((breadcrumb, index) => (
<li key={index} className="flex gap-4">
<ChevronRightIcon className="h-5 w-5 flex-shrink-0 brightness-disabled" />
<span className="cursor-pointer text-sm font-medium hover:brightness-disabled">
{breadcrumb}
</span>
</li>
))}
</ol>
<div className="mt-2 flex items-center">
<h2 className="text-3xl font-bold tracking-tight text-textPrimary">
{title}
</h2>
<div className="ml-auto flex gap-2">
<Button
type="button"
onClick={() => {
reset();
}}
iconBefore={<ArrowUturnLeftIcon className="w-4" />}
>
Reset
</Button>
<Button
disabled={!dirty}
iconBefore={<CheckIcon className="w-4" />}
>
Save
</Button>
</div>
</div>
</div>
<div className="flex flex-col gap-3 p-2">{children}</div>
</form> </form>
); );
}; };

2
src/components/generic/TabbedContent.tsx

@ -28,7 +28,7 @@ export const TabbedContent = ({
actions actions
}: TabbedContentProps): JSX.Element => { }: TabbedContentProps): JSX.Element => {
return ( return (
<Tab.Group as="div" className="flex flex-grow flex-col gap-2"> <Tab.Group as="div" className="flex flex-grow flex-col">
<Tab.List className="flex bg-backgroundPrimary"> <Tab.List className="flex bg-backgroundPrimary">
{tabs.map((entry, index) => ( {tabs.map((entry, index) => (
<Tab key={index} disabled={entry.disabled}> <Tab key={index} disabled={entry.disabled}>

77
src/pages/Config/DeviceConfig.tsx

@ -11,6 +11,9 @@ import { Power } from "@components/PageComponents/Config/Power.js";
import { User } from "@components/PageComponents/Config/User.js"; import { User } from "@components/PageComponents/Config/User.js";
import { useDevice } from "@core/providers/useDevice.js"; import { useDevice } from "@core/providers/useDevice.js";
import { Tab } from "@headlessui/react"; import { Tab } from "@headlessui/react";
import { ChevronRightIcon, HomeIcon } from "@heroicons/react/24/outline";
import { Button } from "@app/components/form/Button.js";
import { CheckIcon } from "@primer/octicons-react";
export const DeviceConfig = (): JSX.Element => { export const DeviceConfig = (): JSX.Element => {
const { hardware } = useDevice(); const { hardware } = useDevice();
@ -52,31 +55,53 @@ export const DeviceConfig = (): JSX.Element => {
]; ];
return ( return (
<Tab.Group as="div" className="flex w-full gap-3"> <div className="w-full">
<Tab.List className="flex w-44 flex-col gap-1"> <div className="m-2 flex rounded-md bg-backgroundSecondary p-2">
{configSections.map((Config, index) => ( <ol className="my-auto ml-2 flex gap-4 text-textSecondary">
<Tab key={index} as={Fragment}> <li className="cursor-pointer hover:brightness-disabled">
{({ selected }) => ( <HomeIcon className="h-5 w-5 flex-shrink-0" />
<div </li>
className={`flex cursor-pointer items-center rounded-md px-3 py-2 text-sm font-medium ${ {["Config", "User"].map((breadcrumb, index) => (
selected <li key={index} className="flex gap-4">
? "bg-gray-100 text-gray-900" <ChevronRightIcon className="h-5 w-5 flex-shrink-0 brightness-disabled" />
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900" <span className="cursor-pointer text-sm font-medium hover:brightness-disabled">
}`} {breadcrumb}
> </span>
{Config.label} </li>
</div> ))}
)} </ol>
</Tab> <div className="ml-auto">
))} <Button iconBefore={<CheckIcon className="w-4" />}>Save</Button>
</Tab.List> </div>
<Tab.Panels as={Fragment}> </div>
{configSections.map((Config, index) => (
<Tab.Panel key={index} as={Fragment}> <Tab.Group as="div" className="flex w-full gap-3">
<Config.element /> <Tab.List className="flex w-44 flex-col">
</Tab.Panel> {configSections.map((Config, index) => (
))} <Tab key={index} as={Fragment}>
</Tab.Panels> {({ selected }) => (
</Tab.Group> <div
className={`flex cursor-pointer items-center border-l-4 p-4 text-sm font-medium ${
selected
? "border-accent bg-accentMuted bg-opacity-10 text-textPrimary"
: "border-backgroundPrimary text-textSecondary"
}`}
>
{Config.label}
<span className="ml-auto bg-accent rounded-full px-3 text-textPrimary">3</span>
</div>
)}
</Tab>
))}
</Tab.List>
<Tab.Panels as={Fragment}>
{configSections.map((Config, index) => (
<Tab.Panel key={index} as={Fragment}>
<Config.element />
</Tab.Panel>
))}
</Tab.Panels>
</Tab.Group>
</div>
); );
}; };

Loading…
Cancel
Save