feat: tag missing location for assets
Signed-off-by: Varun Raj <mailme@varunraj.in>
This commit is contained in:
1 parent
abe002e7bd
commit
4f6117ded2
13 files changed
+301
-126
No files matched your search
@@ -8,6 +8,7 @@ import Lightbox, { SlideImage, SlideTypes } from "yet-another-react-lightbox";
|
||||
import Captions from "yet-another-react-lightbox/plugins/captions";
|
||||
import { CalendarArrowDown, CalendarArrowUp, Hourglass } from "lucide-react";
|
||||
import Video from "yet-another-react-lightbox/plugins/video";
|
||||
|
||||
export default function PotentialAlbumsAssets() {
|
||||
const { startDate, selectedIds, assets, updateContext } =
|
||||
usePotentialAlbumContext();
|
||||
|
||||
@@ -6,14 +6,24 @@ import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Gallery } from "react-grid-gallery";
|
||||
import Lightbox from "yet-another-react-lightbox";
|
||||
import Captions from "yet-another-react-lightbox/plugins/captions";
|
||||
import { CalendarArrowDown, CalendarArrowUp, Hourglass } from "lucide-react";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
CalendarArrowDown,
|
||||
CalendarArrowUp,
|
||||
Hourglass,
|
||||
Link,
|
||||
} from "lucide-react";
|
||||
import { useMissingLocationContext } from "@/contexts/MissingLocationContext";
|
||||
import { listMissingLocationAssets } from "@/handlers/api/asset.handler";
|
||||
import { formatDate, parseDate } from "@/helpers/date.helper";
|
||||
import { addDays } from "date-fns";
|
||||
import { useConfig } from "@/contexts/ConfigContext";
|
||||
|
||||
export default function MissingLocationAssets() {
|
||||
const { startDate, selectedIds, assets, updateContext } = useMissingLocationContext();
|
||||
const { exImmichUrl } = useConfig();
|
||||
|
||||
const { startDate, selectedIds, assets, updateContext } =
|
||||
useMissingLocationContext();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
@@ -25,7 +35,7 @@ export default function MissingLocationAssets() {
|
||||
setLoading(true);
|
||||
updateContext({
|
||||
assets: [],
|
||||
})
|
||||
});
|
||||
return listMissingLocationAssets({ startDate })
|
||||
.then((assets) => updateContext({ assets }))
|
||||
.catch(setErrorMessage)
|
||||
@@ -40,6 +50,16 @@ export default function MissingLocationAssets() {
|
||||
width: p.exifImageWidth as number,
|
||||
height: p.exifImageHeight as number,
|
||||
isSelected: selectedIds.includes(p.id),
|
||||
tags: [
|
||||
{
|
||||
title: "Immich Link",
|
||||
value: (
|
||||
<a href={exImmichUrl + "/photos/" + p.id} target="_blank">
|
||||
Open in Immich
|
||||
</a>
|
||||
),
|
||||
},
|
||||
],
|
||||
}));
|
||||
}, [assets, selectedIds]);
|
||||
|
||||
@@ -56,36 +76,40 @@ export default function MissingLocationAssets() {
|
||||
const handleClick = (idx: number) => setIndex(idx);
|
||||
|
||||
const handleSelect = (_idx: number, asset: IAsset) => {
|
||||
const isPresent =selectedIds.includes(asset.id)
|
||||
if(isPresent){
|
||||
updateContext({selectedIds:selectedIds.filter((id)=>id!==asset.id)})
|
||||
const isPresent = selectedIds.includes(asset.id);
|
||||
if (isPresent) {
|
||||
updateContext({
|
||||
selectedIds: selectedIds.filter((id) => id !== asset.id),
|
||||
});
|
||||
} else {
|
||||
updateContext({ selectedIds: [...selectedIds, asset.id] });
|
||||
}
|
||||
else{
|
||||
updateContext({selectedIds:[...selectedIds,asset.id]})
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (startDate) fetchAssets();
|
||||
}, [startDate]);
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex flex-col gap-2 h-full justify-center items-center w-full">
|
||||
<Hourglass />
|
||||
<p className="text-lg">Loading...</p>
|
||||
</div>
|
||||
)
|
||||
if (loading)
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-full justify-center items-center w-full">
|
||||
<Hourglass />
|
||||
<p className="text-lg">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!startDate)
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-full justify-center items-center w-full">
|
||||
<CalendarArrowUp />
|
||||
<p className="text-lg">Please select a date</p>
|
||||
<p className="text-sm">
|
||||
When you select a date from the left, you will see all the orphan
|
||||
assets captured on that date
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!startDate) return (
|
||||
<div className="flex flex-col gap-2 h-full justify-center items-center w-full">
|
||||
<CalendarArrowUp />
|
||||
<p className="text-lg">Please select a date</p>
|
||||
<p className="text-sm">
|
||||
When you select a date from the left, you will see all the orphan assets captured on that date
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<Lightbox
|
||||
@@ -101,7 +125,13 @@ export default function MissingLocationAssets() {
|
||||
onClick={handleClick}
|
||||
enableImageSelection={true}
|
||||
onSelect={handleSelect}
|
||||
|
||||
tagStyle={{
|
||||
color: "white",
|
||||
fontSize: "12px",
|
||||
backgroundColor: "rgba(0, 0, 0)",
|
||||
padding: "2px",
|
||||
borderRadius: "5px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import ErrorBlock from "@/components/shared/ErrorBlock";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import Loader from "@/components/ui/loader";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { searchPlaces } from "@/handlers/api/common.handler";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IPlace } from "@/types/common";
|
||||
import clsx from "clsx";
|
||||
import { Check } from "lucide-react";
|
||||
import React, { useRef, useState } from "react";
|
||||
|
||||
interface ITagMissingLocationDialogProps {
|
||||
onSubmit: (place: IPlace) => Promise<any>;
|
||||
}
|
||||
export default function TagMissingLocationDialog({
|
||||
onSubmit,
|
||||
}: ITagMissingLocationDialogProps) {
|
||||
const [searchedPlaces, setSearchedPlaces] = useState<IPlace[] | null>(null);
|
||||
const searchTimer = useRef<NodeJS.Timeout>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [selectedPlace, setSelectedPlace] = useState<IPlace | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
if (searchTimer.current) {
|
||||
clearTimeout(searchTimer.current);
|
||||
}
|
||||
|
||||
searchTimer.current = setTimeout(() => {
|
||||
if (!value || !value.trim().length) {
|
||||
setSearchedPlaces(null);
|
||||
return;
|
||||
}
|
||||
setSelectedPlace(null);
|
||||
setSearchedPlaces([]);
|
||||
setLoading(true);
|
||||
if (!value) {
|
||||
setSearchedPlaces([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
return searchPlaces(value)
|
||||
.then(setSearchedPlaces)
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleSelect = (place: IPlace) => {
|
||||
if (selectedPlace && selectedPlace.name === place.name) {
|
||||
setSelectedPlace(null);
|
||||
} else {
|
||||
setSelectedPlace(place);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedPlace) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
onSubmit(selectedPlace)
|
||||
.then(() => {
|
||||
toast({
|
||||
title: "Location updated",
|
||||
description: "Location updated successfully",
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
setOpen(false);
|
||||
setSelectedPlace(null);
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Error updating location",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Tag Missing Location</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tag Missing Location</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tagging a location will add the location to the selected assets.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Search Location</Label>
|
||||
<Input
|
||||
placeholder="Search location"
|
||||
onChange={(e) => {
|
||||
handleSearch(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{loading && <Loader />}
|
||||
{!loading && searchedPlaces && searchedPlaces.length === 0 && (
|
||||
<ErrorBlock description="No results found" />
|
||||
)}
|
||||
{!loading && searchedPlaces && searchedPlaces.length > 0 && (
|
||||
<div className="flex flex-col gap-2 max-h-[400px] overflow-y-auto">
|
||||
{searchedPlaces.map((place) => (
|
||||
<div
|
||||
key={place.name}
|
||||
onClick={() => handleSelect(place)}
|
||||
className={cn(
|
||||
"hover:bg-zinc-900 flex justify-between items-center px-2 py-1 rounded-lg cursor-pointer",
|
||||
{
|
||||
"bg-zinc-900":
|
||||
selectedPlace && selectedPlace.name === place.name,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<p>{place.name}</p>
|
||||
<span className="text-xs text-gray-600">
|
||||
{place.latitude}, {place.longitude}
|
||||
</span>
|
||||
</div>
|
||||
{selectedPlace && selectedPlace.name === place.name && (
|
||||
<Check className="text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="self-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSubmit}
|
||||
disabled={!selectedPlace || submitting}
|
||||
>
|
||||
Add New Location
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -115,17 +115,7 @@ export function PersonMergeDropdown({
|
||||
if (primaryPerson.name.length === 0 && value.name.length > 0) {
|
||||
setPrimaryPerson(value);
|
||||
}
|
||||
// setOpen(false);
|
||||
// return mergePerson(person.id, value.id)
|
||||
// .then(() => {
|
||||
// onRemove?.(person);
|
||||
// })
|
||||
// .then(() => {
|
||||
// toast({
|
||||
// title: "Success",
|
||||
// description: "Person merged successfully",
|
||||
// });
|
||||
// });
|
||||
|
||||
};
|
||||
|
||||
const handleMerge = () => {
|
||||
|
||||
@@ -20,12 +20,16 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import Head from "next/head";
|
||||
import { sidebarNavs } from "@/config/constants/sidebarNavs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
interface IProps {
|
||||
leftComponent?: React.ReactNode | string;
|
||||
rightComponent?: React.ReactNode | string;
|
||||
}
|
||||
export default function Header({ leftComponent, rightComponent }: IProps) {
|
||||
const { pathname } = useRouter();
|
||||
const renderLeftComponent = () => {
|
||||
if (typeof leftComponent === "string") {
|
||||
return <p className="font-semibold">{leftComponent}</p>;
|
||||
@@ -57,68 +61,24 @@ export default function Header({ leftComponent, rightComponent }: IProps) {
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="flex flex-col">
|
||||
<nav className="grid gap-2 text-lg font-medium">
|
||||
<Link
|
||||
href="#"
|
||||
className="flex items-center gap-2 text-lg font-semibold"
|
||||
>
|
||||
<Package2 className="h-6 w-6" />
|
||||
<span className="sr-only">Acme Inc</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Home className="h-5 w-5" />
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl bg-muted px-3 py-2 text-foreground hover:text-foreground"
|
||||
>
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
Orders
|
||||
<Badge className="ml-auto flex h-6 w-6 shrink-0 items-center justify-center rounded-full">
|
||||
6
|
||||
</Badge>
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Package className="h-5 w-5" />
|
||||
Products
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Users className="h-5 w-5" />
|
||||
Customers
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<LineChart className="h-5 w-5" />
|
||||
Analytics
|
||||
</Link>
|
||||
{sidebarNavs.map((nav) => (
|
||||
<Link
|
||||
key={nav.title}
|
||||
href={nav.link}
|
||||
className={
|
||||
cn("mx-[-0.65rem] flex items-center gap-4 rounded-xl px-3 py-2 ", {
|
||||
"bg-muted text-foreground": nav.link === pathname,
|
||||
"text-muted-foreground hover:text-foreground": nav.link !== pathname,
|
||||
})
|
||||
}
|
||||
>
|
||||
{nav.icon}
|
||||
{nav.title}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
</nav>
|
||||
<div className="mt-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Upgrade to Pro</CardTitle>
|
||||
<CardDescription>
|
||||
Unlock all features and get unlimited access to our support
|
||||
team.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button size="sm" className="w-full">
|
||||
Upgrade
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<div className="w-full flex-1">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GalleryVerticalEnd, Home, Image, User } from "lucide-react";
|
||||
import { GalleryVerticalEnd, Home, Image, LocateOff, User } from "lucide-react";
|
||||
|
||||
export const sidebarNavs = [
|
||||
{
|
||||
@@ -16,4 +16,9 @@ export const sidebarNavs = [
|
||||
link: "/albums/potential-albums",
|
||||
icon: <GalleryVerticalEnd className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
title: "Missing Locations",
|
||||
link: "/assets/missing-locations",
|
||||
icon: <LocateOff className="h-4 w-4" />,
|
||||
},
|
||||
];
|
||||
@@ -23,7 +23,12 @@ export const ADD_ASSETS_ALBUMS_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/al
|
||||
// Assets
|
||||
export const LIST_MISSING_LOCATION_DATES_PATH = BASE_API_ENDPOINT + "/assets/missing-location-dates";
|
||||
export const LIST_MISSING_LOCATION_ASSETS_PATH = BASE_API_ENDPOINT + "/assets/missing-location-assets";
|
||||
export const UPDATE_ASSETS_PATH = BASE_PROXY_ENDPOINT + "/assets";
|
||||
|
||||
export const ASSET_THUMBNAIL_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/thumbnail/" + id;
|
||||
export const ASSET_PREVIEW_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/thumbnail/" + id + "?size=preview";
|
||||
export const ASSET_VIDEO_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/video/" + id;
|
||||
export const ASSET_VIDEO_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/video/" + id;
|
||||
|
||||
// Location
|
||||
|
||||
export const SEARCH_PLACES_PATH = BASE_PROXY_ENDPOINT + "/search/places";
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
LIST_ALBUMS_PATH,
|
||||
LIST_MISSING_LOCATION_ASSETS_PATH,
|
||||
LIST_MISSING_LOCATION_DATES_PATH,
|
||||
UPDATE_ASSETS_PATH,
|
||||
} from "@/config/routes";
|
||||
import { cleanUpAsset } from "@/helpers/asset.helper";
|
||||
import API from "@/lib/api";
|
||||
@@ -30,3 +31,15 @@ export const listMissingLocationAssets = async (
|
||||
assets.map(cleanUpAsset)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export interface IUpdateAssetsParams {
|
||||
ids: string[];
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
}
|
||||
|
||||
export const updateAssets = async (params: IUpdateAssetsParams) => {
|
||||
return API.put(UPDATE_ASSETS_PATH, params);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { SEARCH_PLACES_PATH } from "@/config/routes";
|
||||
import API from "@/lib/api";
|
||||
import { IPlace } from "@/types/common";
|
||||
|
||||
export const searchPlaces = async (name: string): Promise<IPlace[]> => {
|
||||
return API.get(SEARCH_PLACES_PATH, { name });
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ const SELECT_ORPHAN_PHOTOS = (date: string, ownerId: string) =>
|
||||
aaa."albumsId" IS NULL
|
||||
AND a."ownerId" = '${ownerId}'
|
||||
AND a."localDateTime"::date = '${date}'
|
||||
AND a."previewPath" IS NOT NULL
|
||||
`);
|
||||
|
||||
export default async function handler(
|
||||
|
||||
@@ -55,6 +55,7 @@ export default async function handler(
|
||||
lte(exif.dateTimeOriginal, endDateDate),
|
||||
eq(assets.type, "VIDEO"),
|
||||
eq(assets.ownerId, currentUser.id),
|
||||
isNotNull(assets.previewPath),
|
||||
));
|
||||
|
||||
return res.status(200).json(rows);
|
||||
|
||||
@@ -3,6 +3,7 @@ import PotentialAlbumsAssets from "@/components/albums/potential-albums/Potentia
|
||||
import PotentialAlbumsDates from "@/components/albums/potential-albums/PotentialAlbumsDates";
|
||||
import MissingLocationAssets from "@/components/assets/missing-location/MissingLocationAssets";
|
||||
import MissingLocationDates from "@/components/assets/missing-location/MissingLocationDates";
|
||||
import TagMissingLocationDialog from "@/components/assets/missing-location/TagMissingLocationDialog";
|
||||
import PageLayout from "@/components/layouts/PageLayout";
|
||||
import Header from "@/components/shared/Header";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -11,9 +12,9 @@ import { useToast } from "@/components/ui/use-toast";
|
||||
import MissingLocationContext, {
|
||||
IMissingLocationConfig,
|
||||
} from "@/contexts/MissingLocationContext";
|
||||
import { updateAssets } from "@/handlers/api/asset.handler";
|
||||
|
||||
import { addAssetToAlbum } from "@/handlers/api/album.handler";
|
||||
import { IAlbum } from "@/types/album";
|
||||
import { IPlace } from "@/types/common";
|
||||
import React from "react";
|
||||
|
||||
export default function MissingLocations() {
|
||||
@@ -25,31 +26,17 @@ export default function MissingLocations() {
|
||||
assets: [],
|
||||
});
|
||||
|
||||
const handleSelect = (album: IAlbum) => {
|
||||
return addAssetToAlbum(album.id, config.selectedIds)
|
||||
.then(() => {
|
||||
setConfig({ ...config, selectedIds: [] });
|
||||
setConfig({
|
||||
...config,
|
||||
assets: config.assets.filter(
|
||||
(asset) => !config.selectedIds.includes(asset.id)
|
||||
),
|
||||
selectedIds: [],
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
toast({
|
||||
title: `Assets added to ${album.albumName}`,
|
||||
description: `${config.selectedIds.length} assets added to album`,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to add assets album",
|
||||
variant: "destructive",
|
||||
});
|
||||
const handleSubmit = (place: IPlace) => {
|
||||
return updateAssets({
|
||||
ids: config.selectedIds,
|
||||
latitude: place.latitude,
|
||||
longitude: place.longitude,
|
||||
}).then(() => {
|
||||
setConfig({
|
||||
...config,
|
||||
selectedIds: [],
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -86,6 +73,7 @@ export default function MissingLocations() {
|
||||
Select all
|
||||
</Button>
|
||||
)}
|
||||
<TagMissingLocationDialog onSubmit={handleSubmit} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
Vendored
+6
@@ -2,4 +2,10 @@ export interface IListData {
|
||||
hasNextPage: boolean,
|
||||
total: number,
|
||||
hidden: number,
|
||||
}
|
||||
|
||||
export interface IPlace {
|
||||
name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
Reference in new issue
Block a user