Browse Source

more components updated with missing translations

pull/627/head
Dan Ditomaso 1 year ago
parent
commit
a59bd805ae
  1. 10
      src/components/BatteryStatus.tsx
  2. 61
      src/components/Form/FormMultiSelect.tsx
  3. 6
      src/components/PageComponents/Connect/Serial.tsx
  4. 8
      src/components/Sidebar.tsx
  5. 15
      src/components/ThemeSwitcher.tsx
  6. 31
      src/components/UI/Footer.tsx
  7. 4
      src/components/generic/Filter/FilterControl.tsx
  8. 99
      src/core/hooks/usePositionFlags.ts
  9. 24
      src/i18n/locales/en.json
  10. 10
      src/pages/Channels.tsx

10
src/components/BatteryStatus.tsx

@ -32,25 +32,25 @@ const getBatteryStates = (
condition: (level) => level > 100, condition: (level) => level > 100,
Icon: PlugZapIcon, Icon: PlugZapIcon,
className: "text-gray-500", className: "text-gray-500",
text: () => t("common_batteryStatus.pluggedIn"), text: () => t("common_batteryStatus_pluggedIn"),
}, },
{ {
condition: (level) => level > 80, condition: (level) => level > 80,
Icon: BatteryFullIcon, Icon: BatteryFullIcon,
className: "text-green-500", className: "text-green-500",
text: (level) => t("common_batteryStatus.charging", { level }), text: (level) => t("common_batteryStatus_charging", { level }),
}, },
{ {
condition: (level) => level > 20, condition: (level) => level > 20,
Icon: BatteryMediumIcon, Icon: BatteryMediumIcon,
className: "text-yellow-500", className: "text-yellow-500",
text: (level) => t("common_batteryStatus.charging", { level }), text: (level) => t("common_batteryStatus_charging", { level }),
}, },
{ {
condition: () => true, condition: () => true,
Icon: BatteryLowIcon, Icon: BatteryLowIcon,
className: "text-red-500", className: "text-red-500",
text: (level) => t("common_batteryStatus.charging", { level }), text: (level) => t("common_batteryStatus_charging", { level }),
}, },
]; ];
}; };
@ -91,7 +91,7 @@ const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
title={voltageTitle} title={voltageTitle}
> >
<BatteryIcon size={22} className={iconClassName} /> <BatteryIcon size={22} className={iconClassName} />
<Subtle aria-label={t("common_batteryStatus.title")}> <Subtle aria-label={t("common_batteryStatus_title")}>
{statusText} {statusText}
</Subtle> </Subtle>
</div> </div>

61
src/components/Form/FormMultiSelect.tsx

@ -3,6 +3,8 @@ import type {
GenericFormElementProps, GenericFormElementProps,
} from "@components/Form/DynamicForm.tsx"; } from "@components/Form/DynamicForm.tsx";
import type { FieldValues } from "react-hook-form"; import type { FieldValues } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { FLAGS_CONFIG } from "@core/hooks/usePositionFlags.ts";
import { MultiSelect, MultiSelectItem } from "../UI/MultiSelect.tsx"; import { MultiSelect, MultiSelectItem } from "../UI/MultiSelect.tsx";
export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> { export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
@ -12,51 +14,50 @@ export interface MultiSelectFieldProps<T> extends BaseFormBuilderProps<T> {
isChecked: (name: string) => boolean; isChecked: (name: string) => boolean;
value: string[]; value: string[];
properties: BaseFormBuilderProps<T>["properties"] & { properties: BaseFormBuilderProps<T>["properties"] & {
enumValue: { enumValue:
[s: string]: string | number; | { [s: string]: string | number }
}; | typeof FLAGS_CONFIG;
formatEnumName?: boolean; formatEnumName?: boolean;
}; };
} }
const formatEnumDisplay = (name: string): string => {
return name
.replace(/_/g, " ")
.toLowerCase()
.split(" ")
.map((s) => s.charAt(0).toUpperCase() + s.substring(1))
.join(" ");
};
export function MultiSelectInput<T extends FieldValues>({ export function MultiSelectInput<T extends FieldValues>({
field, field,
}: GenericFormElementProps<T, MultiSelectFieldProps<T>>) { }: GenericFormElementProps<T, MultiSelectFieldProps<T>>) {
const { enumValue, formatEnumName, ...remainingProperties } = const { t } = useTranslation();
field.properties; const { enumValue, ...remainingProperties } = field.properties;
const valueToKeyMap: Record<string, string> = {}; const isNewConfigStructure =
const optionsEnumValues: [string, number][] = []; typeof Object.values(enumValue)[0] === "object" &&
Object.values(enumValue)[0] !== null &&
"i18nKey" in Object.values(enumValue)[0];
if (enumValue) { const optionsToRender = Object.entries(enumValue).map(
Object.entries(enumValue).forEach(([key, val]) => { ([key, configOrValue]) => {
if (typeof val === "number" && key !== "UNSET") { if (isNewConfigStructure) {
valueToKeyMap[val.toString()] = key; const config =
optionsEnumValues.push([key, val as number]); configOrValue as typeof FLAGS_CONFIG[keyof typeof FLAGS_CONFIG];
return {
key,
display: t(config.i18nKey),
value: config.value,
};
} }
}); return { key, display: key, value: configOrValue as number };
} },
);
return ( return (
<MultiSelect {...remainingProperties}> <MultiSelect {...remainingProperties}>
{optionsEnumValues.map(([name, value]) => ( {optionsToRender.map((option) => (
<MultiSelectItem <MultiSelectItem
key={name} key={option.key}
name={name} name={option.key}
value={value.toString()} value={option.value.toString()}
checked={field.isChecked(name)} checked={field.isChecked(option.key)}
onCheckedChange={() => field.onValueChange(name)} onCheckedChange={() => field.onValueChange(option.key)}
> >
{formatEnumName ? formatEnumDisplay(name) : name} {option.display}
</MultiSelectItem> </MultiSelectItem>
))} ))}
</MultiSelect> </MultiSelect>

6
src/components/PageComponents/Connect/Serial.tsx

@ -69,7 +69,7 @@ export const Serial = (
// No need to setConnectionInProgress(false) here as closeDialog() unmounts. // No need to setConnectionInProgress(false) here as closeDialog() unmounts.
}} }}
> >
{t("serialConnection.deviceIdentifier", { {t("serialConnection_deviceIdentifier", {
index, index,
vendorId: vendor, vendorId: vendor,
productId: product, productId: product,
@ -79,7 +79,7 @@ export const Serial = (
})} })}
{serialPorts.length === 0 && ( {serialPorts.length === 0 && (
<Mono className="m-auto select-none"> <Mono className="m-auto select-none">
{t("serialConnection.noDevicesPaired")} {t("serialConnection_noDevicesPaired")}
</Mono> </Mono>
)} )}
</div> </div>
@ -96,7 +96,7 @@ export const Serial = (
}); });
}} }}
> >
<span>{t("serialConnection.newDeviceButton")}</span> <span>{t("serialConnection_newDeviceButton")}</span>
</Button> </Button>
</fieldset> </fieldset>
); );

8
src/components/Sidebar.tsx

@ -97,7 +97,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
}, },
{ name: t("navigation_title_map"), icon: MapIcon, page: "map" }, { name: t("navigation_title_map"), icon: MapIcon, page: "map" },
{ {
name: t("navigation_title_radioConfig"), name: t("navigation_title_config"),
icon: SettingsIcon, icon: SettingsIcon,
page: "config", page: "config",
}, },
@ -236,7 +236,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0" className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0"
/> />
<Subtle> <Subtle>
{t("sidebar_deviceInfo.volts", { {t("sidebar_deviceInfo_volts", {
voltage: myNode.deviceMetrics?.voltage?.toPrecision(3) ?? voltage: myNode.deviceMetrics?.voltage?.toPrecision(3) ??
t("common_unknown_short"), t("common_unknown_short"),
})} })}
@ -248,7 +248,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0" className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0"
/> />
<Subtle> <Subtle>
{t("sidebar_deviceInfo.firmwareVersion", { {t("sidebar_deviceInfo_firmwareVersion", {
version: myMetadata?.firmwareVersion ?? version: myMetadata?.firmwareVersion ??
t("common_unknown_short"), t("common_unknown_short"),
})} })}
@ -266,7 +266,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
> >
<button <button
type="button" type="button"
aria-label={t("sidebar_deviceInfo.button_editDeviceName")} aria-label={t("sidebar_deviceInfo_button_editDeviceName")}
className="p-1 rounded transition-colors cursor-pointer hover:text-accent" className="p-1 rounded transition-colors cursor-pointer hover:text-accent"
onClick={() => setDialogOpen("deviceName", true)} onClick={() => setDialogOpen("deviceName", true)}
> >

15
src/components/ThemeSwitcher.tsx

@ -1,6 +1,7 @@
import { useTheme } from "../core/hooks/useTheme.ts"; import { useTheme } from "../core/hooks/useTheme.ts";
import { cn } from "../core/utils/cn.ts"; import { cn } from "../core/utils/cn.ts";
import { Monitor, Moon, Sun } from "lucide-react"; import { Monitor, Moon, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
type ThemePreference = "light" | "dark" | "system"; type ThemePreference = "light" | "dark" | "system";
@ -10,6 +11,7 @@ export default function ThemeSwitcher({
className?: string; className?: string;
}) { }) {
const { preference, setPreference } = useTheme(); const { preference, setPreference } = useTheme();
const { t } = useTranslation();
const themeIcons = { const themeIcons = {
light: <Sun className="size-6" />, light: <Sun className="size-6" />,
@ -24,7 +26,13 @@ export default function ThemeSwitcher({
setPreference(nextPreference); setPreference(nextPreference);
}; };
const [firstCharOfPreference = "", ...restOfPreference] = preference; const preferenceDisplayMap: Record<ThemePreference, string> = {
light: t("theme_preference_light"),
dark: t("theme_preference_dark"),
system: t("theme_preference_system"),
};
const currentDisplayPreference = preferenceDisplayMap[preference];
return ( return (
<button <button
@ -34,14 +42,13 @@ export default function ThemeSwitcher({
className, className,
)} )}
onClick={toggleTheme} onClick={toggleTheme}
aria-description="Change current theme" aria-description={t("theme_switcher_aria_change_theme")}
> >
<span <span
data-label data-label
className="transition-all block absolute w-full mb-auto mt-auto ml-0 mr-0 text-xs left-0 -top-3 opacity-0 rounded-lg" className="transition-all block absolute w-full mb-auto mt-auto ml-0 mr-0 text-xs left-0 -top-3 opacity-0 rounded-lg"
> >
{firstCharOfPreference.toLocaleUpperCase() + {currentDisplayPreference}
(restOfPreference ?? []).join("")}
</span> </span>
{themeIcons[preference]} {themeIcons[preference]}
</button> </button>

31
src/components/UI/Footer.tsx

@ -1,4 +1,5 @@
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { Trans, useTranslation } from "react-i18next";
type FooterProps = { type FooterProps = {
className?: string; className?: string;
@ -14,19 +15,23 @@ const Footer = ({ className, ...props }: FooterProps) => {
{...props} {...props}
> >
<p> <p>
<a <Trans
href="https://vercel.com/?utm_source=meshtastic&utm_campaign=oss" i18nKey="footer_text"
className="hover:underline text-link" components={[
> <a
Powered by Vercel key="vercel"
</a>{" "} rel="noopener noreferrer"
| Meshtastic® is a registered trademark of Meshtastic LLC. |{" "} href="https://vercel.com/?utm_source=meshtastic&utm_campaign=oss"
<a className="hover:underline text-link"
href="https://meshtastic.org/docs/legal" />,
className="hover:underline text-link" <a
> key="legal"
Legal Information rel="noopener noreferrer"
</a> href="https://meshtastic.org/docs/legal"
className="hover:underline text-link"
/>,
]}
/>
</p> </p>
</footer> </footer>
); );

4
src/components/generic/Filter/FilterControl.tsx

@ -278,7 +278,7 @@ export function FilterControl({
<> <>
{t("filter_control_slider_batteryLevel_labelText", { {t("filter_control_slider_batteryLevel_labelText", {
value: localFilterState.batteryLevel[0] === 101 value: localFilterState.batteryLevel[0] === 101
? t("common_batteryStatus.pluggedIn") ? t("common_batteryStatus_pluggedIn")
: localFilterState.batteryLevel[0], : localFilterState.batteryLevel[0],
})} })}
{localFilterState.batteryLevel[0] !== {localFilterState.batteryLevel[0] !==
@ -286,7 +286,7 @@ export function FilterControl({
<> <>
{" – "} {" – "}
{localFilterState.batteryLevel[1] === 101 {localFilterState.batteryLevel[1] === 101
? t("common_batteryStatus.pluggedIn") ? t("common_batteryStatus_pluggedIn")
: localFilterState.batteryLevel[1]} : localFilterState.batteryLevel[1]}
</> </>
)} )}

99
src/core/hooks/usePositionFlags.ts

@ -1,27 +1,44 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
const FLAGS = { export const FLAGS_CONFIG = {
UNSET: 0, UNSET: { value: 0, i18nKey: "position_flag_unset" },
Altitude: 1, ALTITUDE: { value: 1, i18nKey: "position_flag_altitude" },
"Altitude is Mean Sea Level": 2, ALTITUDE_MSL: { value: 2, i18nKey: "position_flag_altitude_msl" },
"Altitude Geoidal Seperation": 4, ALTITUDE_GEOIDAL_SEPARATION: {
"Dilution of precision (DOP) PDOP used by default": 8, value: 4,
"If DOP is set, use HDOP / VDOP values instead of PDOP": 16, i18nKey: "position_flag_altitude_geoidal_separation",
"Number of satellites": 32, },
"Sequence number": 64, DOP: {
Timestamp: 128, value: 8,
"Vehicle heading": 256, i18nKey: "position_flag_dop",
"Vehicle speed": 512, },
HDOP_VDOP: {
value: 16,
i18nKey: "position_flag_hdop_vdop",
},
NUM_SATELLITES: {
value: 32,
i18nKey: "position_flag_num_satellites",
},
SEQUENCE_NUMBER: {
value: 64,
i18nKey: "position_flag_sequence_number",
},
TIMESTAMP: { value: 128, i18nKey: "position_flag_timestamp" },
VEHICLE_HEADING: {
value: 256,
i18nKey: "position_flag_vehicle_heading",
},
VEHICLE_SPEED: { value: 512, i18nKey: "position_flag_vehicle_speed" },
} as const; } as const;
export type FlagName = keyof typeof FLAGS; export type FlagName = keyof typeof FLAGS_CONFIG;
type FlagsObject = typeof FLAGS;
type UsePositionFlagsProps = { type UsePositionFlagsProps = {
decode: (value: number) => FlagName[]; decode: (value: number) => FlagName[];
encode: (flagNames: FlagName[]) => number; encode: (flagNames: FlagName[]) => number;
hasFlag: (value: number, flagName: FlagName) => boolean; hasFlag: (value: number, flagName: FlagName) => boolean;
getAllFlags: () => FlagsObject; getAllFlags: () => typeof FLAGS_CONFIG;
isValidValue: (value: number) => boolean; isValidValue: (value: number) => boolean;
flagsValue: number; flagsValue: number;
activeFlags: FlagName[]; activeFlags: FlagName[];
@ -34,41 +51,52 @@ type UsePositionFlagsProps = {
export const usePositionFlags = (initialValue = 0): UsePositionFlagsProps => { export const usePositionFlags = (initialValue = 0): UsePositionFlagsProps => {
const [flagsValue, setFlagsValue] = useState<number>(initialValue); const [flagsValue, setFlagsValue] = useState<number>(initialValue);
const FLAGS_BITMASKS = useMemo(() => {
return Object.fromEntries(
Object.entries(FLAGS_CONFIG).map(([key, conf]) => [key, conf.value]),
) as { [K in FlagName]: typeof FLAGS_CONFIG[K]["value"] };
}, []);
const utils = useMemo(() => { const utils = useMemo(() => {
const decode = (value: number): FlagName[] => { const decode = (value: number): FlagName[] => {
if (value === 0) return ["UNSET"]; if (value === FLAGS_CONFIG.UNSET.value) return ["UNSET"];
const activeFlags: FlagName[] = []; const activeFlags: FlagName[] = [];
for (const [name, flagValue] of Object.entries(FLAGS)) { for (const key in FLAGS_CONFIG) {
if (flagValue !== 0 && (value & flagValue) === flagValue) { const flagName = key as FlagName;
activeFlags.push(name as FlagName); const flagConfig = FLAGS_CONFIG[flagName];
if (
flagConfig.value !== 0 &&
(value & flagConfig.value) === flagConfig.value
) {
activeFlags.push(flagName);
} }
} }
return activeFlags; return activeFlags;
}; };
const encode = (flagNames: FlagName[]): number => { const encode = (flagNames: FlagName[]): number => {
if (flagNames.includes("UNSET")) { if (flagNames.includes("UNSET") && flagNames.length === 1) {
return 0; return FLAGS_CONFIG.UNSET.value;
} }
return flagNames.reduce((acc, name) => { return flagNames.reduce((acc, name) => {
const value = FLAGS[name]; if (name === "UNSET") return acc;
return acc | value; return acc | FLAGS_CONFIG[name].value;
}, 0); }, 0);
}; };
const hasFlag = (value: number, flagName: FlagName): boolean => { const hasFlag = (value: number, flagName: FlagName): boolean => {
const flagValue = FLAGS[flagName]; return (value & FLAGS_CONFIG[flagName].value) ===
return (value & flagValue) === flagValue; FLAGS_CONFIG[flagName].value;
}; };
const getAllFlags = (): FlagsObject => { const getAllFlags = (): typeof FLAGS_CONFIG => {
return FLAGS; return FLAGS_CONFIG;
}; };
const isValidValue = (value: number): boolean => { const isValidValue = (value: number): boolean => {
const maxValue = Object.values(FLAGS) const maxValue = Object.values(FLAGS_BITMASKS)
.filter((val) => val !== 0) // Exclude UNSET (0) from the calculation .filter((val) => val !== 0)
.reduce((acc, val) => acc | val, 0); .reduce((acc, val) => acc | val, 0);
return Number.isInteger(value) && value >= 0 && value <= maxValue; return Number.isInteger(value) && value >= 0 && value <= maxValue;
}; };
@ -80,16 +108,17 @@ export const usePositionFlags = (initialValue = 0): UsePositionFlagsProps => {
getAllFlags, getAllFlags,
isValidValue, isValidValue,
}; };
}, []); }, [FLAGS_BITMASKS]);
const toggleFlag = useCallback((flagName: FlagName) => { const toggleFlag = useCallback((flagName: FlagName) => {
const flagValue = FLAGS[flagName]; setFlagsValue((prev) => prev ^ FLAGS_CONFIG[flagName].value);
setFlagsValue((prev) => prev ^ flagValue);
}, []); }, []);
const setFlag = useCallback((flagName: FlagName, enabled: boolean) => { const setFlag = useCallback((flagName: FlagName, enabled: boolean) => {
const flagValue = FLAGS[flagName]; const currentFlagValue = FLAGS_CONFIG[flagName].value;
setFlagsValue((prev) => (enabled ? prev | flagValue : prev & ~flagValue)); setFlagsValue((prev) =>
enabled ? prev | currentFlagValue : prev & ~currentFlagValue
);
}, []); }, []);
const setFlags = useCallback( const setFlags = useCallback(
@ -103,7 +132,7 @@ export const usePositionFlags = (initialValue = 0): UsePositionFlagsProps => {
); );
const clearFlags = useCallback(() => { const clearFlags = useCallback(() => {
setFlagsValue(0); setFlagsValue(FLAGS_CONFIG.UNSET.value);
}, []); }, []);
const activeFlags = utils.decode(flagsValue); const activeFlags = utils.decode(flagsValue);

24
src/i18n/locales/en.json

@ -1,6 +1,6 @@
{ {
"common_title": "Meshtastic Web", "common_title": "Meshtastic Web",
"common_app": "Meshtastic", "common_header": "Meshtastic",
"common_description": "Meshtastic Web Client", "common_description": "Meshtastic Web Client",
"common_button_close": "Close", "common_button_close": "Close",
"common_button_ok": "OK", "common_button_ok": "OK",
@ -31,6 +31,7 @@
"navigation_title": "Navigation", "navigation_title": "Navigation",
"navigation_title_messages": "Messages", "navigation_title_messages": "Messages",
"navigation_title_map": "Map", "navigation_title_map": "Map",
"navigation_title_config": "Config",
"navigation_title_radioConfig": "Radio Config", "navigation_title_radioConfig": "Radio Config",
"navigation_title_moduleConfig": "Module Config", "navigation_title_moduleConfig": "Module Config",
"navigation_title_channels": "Channels", "navigation_title_channels": "Channels",
@ -583,5 +584,24 @@
"filter_control_toggle_favorites_label": "Favorites", "filter_control_toggle_favorites_label": "Favorites",
"filter_control_toggle_hide_label": "Hide", "filter_control_toggle_hide_label": "Hide",
"filter_control_toggle_showOnly_label": "Show Only", "filter_control_toggle_showOnly_label": "Show Only",
"filter_control_toggle_viaMqtt_label": "Connected via MQTT" "filter_control_toggle_viaMqtt_label": "Connected via MQTT",
"position_flag_altitude": "Altitude",
"position_flag_altitude_geoidal_separation": "Altitude Geoidal Separation",
"position_flag_altitude_msl": "Altitude is Mean Sea Level",
"position_flag_dop": "Dilution of precision (DOP) PDOP used by default",
"position_flag_hdop_vdop": "If DOP is set, use HDOP / VDOP values instead of PDOP",
"position_flag_num_satellites": "Number of satellites",
"position_flag_sequence_number": "Sequence number",
"position_flag_timestamp": "Timestamp",
"position_flag_unset": "Unset",
"position_flag_vehicle_heading": "Vehicle heading",
"position_flag_vehicle_speed": "Vehicle speed",
"theme_preference_dark": "Dark",
"theme_preference_light": "Light",
"theme_preference_system": "System",
"theme_switcher_aria_change_theme": "Change current theme",
"footer_text": "Powered by <0>▲ Vercel</0> | Meshtastic® is a registered trademark of Meshtastic LLC. | <1>Legal Information</1>"
} }

10
src/pages/Channels.tsx

@ -11,7 +11,7 @@ import { useDevice } from "@core/stores/deviceStore.ts";
import { Types } from "@meshtastic/core"; import { Types } from "@meshtastic/core";
import type { Protobuf } from "@meshtastic/core"; import type { Protobuf } from "@meshtastic/core";
import i18next from "i18next"; import i18next from "i18next";
import { ImportIcon, QrCodeIcon } from "lucide-react"; import { QrCodeIcon, UploadIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -43,14 +43,14 @@ const ChannelsPage = () => {
: t("common_loading")} : t("common_loading")}
actions={[ actions={[
{ {
key: "search", key: "import",
icon: ImportIcon, icon: UploadIcon,
onClick() { onClick() {
setDialogOpen("import", true); setDialogOpen("import", true);
}, },
}, },
{ {
key: "import", key: "qr",
icon: QrCodeIcon, icon: QrCodeIcon,
onClick() { onClick() {
setDialogOpen("QR", true); setDialogOpen("QR", true);
@ -59,7 +59,7 @@ const ChannelsPage = () => {
]} ]}
> >
<Tabs defaultValue="0"> <Tabs defaultValue="0">
<TabsList className="dark:bg-slate-800 "> <TabsList className="dark:bg-slate-800">
{allChannels.map((channel) => ( {allChannels.map((channel) => (
<TabsTrigger <TabsTrigger
key={channel.index} key={channel.index}

Loading…
Cancel
Save