@@ -3,7 +3,7 @@ name: ci
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- release
|
||||
|
||||
jobs:
|
||||
release:
|
||||
|
||||
Generated
+14431
File diff suppressed because it is too large
Load Diff
@@ -41,9 +41,12 @@
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.0",
|
||||
"compatibility": "^1.0.1",
|
||||
"cookie": "^0.6.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"drizzle-orm": "^0.33.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet-defaulticon-compatibility":"0.1.2",
|
||||
"lucide-react": "^0.428.0",
|
||||
"next": "14.2.5",
|
||||
"next-themes": "^0.3.0",
|
||||
@@ -54,6 +57,7 @@
|
||||
"react-day-picker": "9.0.8",
|
||||
"react-dom": "^18",
|
||||
"react-grid-gallery": "^1.0.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-query": "^3.39.3",
|
||||
"recharts": "^2.12.7",
|
||||
"remotion": "4.0.232",
|
||||
@@ -69,6 +73,7 @@
|
||||
"@semantic-release/npm": "^12.0.1",
|
||||
"@semantic-release/release-notes-generator": "^14.0.1",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/leaflet":"^1.9.15",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.11.6",
|
||||
"@types/react": "^18",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import L, { Marker as LeafletMarker } from 'leaflet';
|
||||
import { Marker, Popup } from 'react-leaflet';
|
||||
import { useRef } from 'react';
|
||||
import { LatLngExpression } from 'leaflet';
|
||||
|
||||
|
||||
interface CustomMarkerProps {
|
||||
position: LatLngExpression;
|
||||
}
|
||||
|
||||
export default function CustomMarker({ position } : CustomMarkerProps) {
|
||||
const markerRef = useRef<LeafletMarker|null>(null);
|
||||
|
||||
return (
|
||||
<Marker
|
||||
position={position}
|
||||
ref={markerRef}
|
||||
eventHandlers={{
|
||||
mouseover: () => {
|
||||
if (markerRef.current) {
|
||||
markerRef.current.openPopup();
|
||||
}
|
||||
},
|
||||
mouseout: () => {
|
||||
if (markerRef.current) {
|
||||
markerRef.current.closePopup();
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
Position: {L.latLng(position).toString()}
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
// IMPORTANT: the order matters!
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import L, { LatLngExpression } from 'leaflet';
|
||||
|
||||
import 'leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.webpack.css'; // Re-uses images from ~leaflet package
|
||||
import 'leaflet-defaulticon-compatibility';
|
||||
|
||||
import { MapContainer, Marker, Popup, TileLayer, useMap, useMapEvents } from "react-leaflet";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { IPlace } from "@/types/common";
|
||||
import CustomMarker from "./CustomMarker";
|
||||
|
||||
interface MapComponentProps {
|
||||
location: IPlace;
|
||||
onLocationChange: (place: IPlace) => void;
|
||||
}
|
||||
|
||||
interface MapMouseHandlerProps {
|
||||
onMouseDown: (coords: LatLngExpression) => void;
|
||||
}
|
||||
|
||||
const MapMouseHandler = ({ onMouseDown }: MapMouseHandlerProps) => {
|
||||
useMapEvents({
|
||||
// Handle mouse events
|
||||
mousedown: (e) => {
|
||||
onMouseDown(e.latlng);
|
||||
},
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function Map({ location, onLocationChange }: MapComponentProps) {
|
||||
const [position, setPosition] = useState<LatLngExpression>([location.latitude, location.longitude]);
|
||||
|
||||
const handleClick = (coords: LatLngExpression) => {
|
||||
const normalized = L.latLng(coords);
|
||||
|
||||
setPosition(coords);
|
||||
onLocationChange({
|
||||
latitude: normalized.lat,
|
||||
longitude: normalized.lng,
|
||||
name: ""
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={position}
|
||||
zoom={14}
|
||||
scrollWheelZoom={true}
|
||||
style={{ width: '500px', height: '400px' }}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<MapMouseHandler onMouseDown={handleClick} />
|
||||
<CustomMarker position={position} />
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
||||
+17
-7
@@ -13,6 +13,11 @@ import React, { useState } from "react";
|
||||
import TagMissingLocationSearchAndAdd from "./TagMissingLocationSearchAndAdd";
|
||||
import TagMissingLocationSearchLatLong from "./TagMissingLocationSearchLatLong";
|
||||
import { MapPinCheck } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const LazyMap = dynamic(() => import("./Map"), {
|
||||
ssr: false
|
||||
});
|
||||
|
||||
interface ITagMissingLocationDialogProps {
|
||||
onSubmit: (place: IPlace) => Promise<any>;
|
||||
@@ -22,6 +27,11 @@ export default function TagMissingLocationDialog({
|
||||
}: ITagMissingLocationDialogProps) {
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mapPosition,setMapPosition] = useState<IPlace>({
|
||||
latitude: 48.0,
|
||||
longitude: 16.0,
|
||||
name: "home1"
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
@@ -29,7 +39,7 @@ export default function TagMissingLocationDialog({
|
||||
<DialogTrigger asChild>
|
||||
<Button>Tag Missing Location</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent className="!w-[600px] !max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tag Missing Location</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -43,18 +53,18 @@ export default function TagMissingLocationDialog({
|
||||
Latitude and Longitude
|
||||
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="google">Google Maps</TabsTrigger>
|
||||
<TabsTrigger value="maps">Map</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="search">
|
||||
<TagMissingLocationSearchAndAdd onSubmit={onSubmit} onOpenChange={setOpen} />
|
||||
</TabsContent>
|
||||
<TabsContent value="latlong">
|
||||
<TagMissingLocationSearchLatLong onSubmit={onSubmit} onOpenChange={setOpen} />
|
||||
<TagMissingLocationSearchLatLong onSubmit={onSubmit}
|
||||
onOpenChange={setOpen} location={mapPosition} onLocationChange={setMapPosition} />
|
||||
</TabsContent>
|
||||
<TabsContent value="google">
|
||||
<div className="py-10 flex flex-col gap-6 items-center">
|
||||
<MapPinCheck size={48} className="text-gray-500"/>
|
||||
<p>Google Maps Coming Soon...</p>
|
||||
<TabsContent value="maps">
|
||||
<div className="py-10 flex flex-col gap-6 items-center ">
|
||||
<LazyMap location={mapPosition} onLocationChange={setMapPosition} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
+25
-8
@@ -8,25 +8,30 @@ 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, { useMemo, useRef, useState } from "react";
|
||||
|
||||
interface TagMissingLocationSearchLatLongProps {
|
||||
onSubmit: (place: IPlace) => Promise<any>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
location: IPlace;
|
||||
onLocationChange: (place: IPlace) => void;
|
||||
}
|
||||
|
||||
export default function TagMissingLocationSearchLatLong(
|
||||
{
|
||||
onSubmit,
|
||||
onOpenChange
|
||||
onOpenChange,
|
||||
location,
|
||||
onLocationChange
|
||||
}: TagMissingLocationSearchLatLongProps
|
||||
) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [formData, setFormData] = useState<IPlace>({
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
name: "",
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
name: location.name,
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
@@ -41,7 +46,8 @@ export default function TagMissingLocationSearchLatLong(
|
||||
}, [formData]);
|
||||
|
||||
const handleChange = (key: string, value: string) => {
|
||||
if (value.includes(",")) {
|
||||
// if somebody pastes a combination of "lat, long"
|
||||
if (key === "latitude" && value.includes(",")) {
|
||||
const latLong = value.split(",");
|
||||
if (latLong.length === 2) {
|
||||
setFormData({
|
||||
@@ -51,7 +57,6 @@ export default function TagMissingLocationSearchLatLong(
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
[key]: value,
|
||||
@@ -92,6 +97,16 @@ export default function TagMissingLocationSearchLatLong(
|
||||
};
|
||||
|
||||
|
||||
const handleBlur = () => {
|
||||
const place :IPlace = {
|
||||
name: formData.name,
|
||||
latitude: parseFloat(formData.latitude as unknown as string),
|
||||
longitude: parseFloat(formData.longitude as unknown as string)
|
||||
};
|
||||
|
||||
onLocationChange(place);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 px-2">
|
||||
<div className="flex items-start gap-2">
|
||||
@@ -103,6 +118,7 @@ export default function TagMissingLocationSearchLatLong(
|
||||
onChange={(e) => {
|
||||
handleChange("latitude", e.target.value);
|
||||
}}
|
||||
onBlur={(e) => handleBlur()}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -115,10 +131,11 @@ export default function TagMissingLocationSearchLatLong(
|
||||
onChange={(e) => {
|
||||
handleChange("longitude", e.target.value);
|
||||
}}
|
||||
onBlur={(e) => handleBlur()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="self-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ArrowUpRight, Info, Search } from "lucide-react";
|
||||
import { useConfig } from "@/contexts/ConfigContext";
|
||||
import { useToast } from "../ui/use-toast";
|
||||
import { Badge } from "../ui/badge";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
interface IProps {
|
||||
person: IPerson;
|
||||
onRemove: (person: IPerson) => void;
|
||||
@@ -78,18 +78,11 @@ export default function PersonItem({ person, onRemove }: IProps) {
|
||||
)}
|
||||
>
|
||||
<div className="relative w-full h-auto group">
|
||||
<div
|
||||
onClick={() => {
|
||||
handleHide(!formData.isHidden);
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
className="w-full min-h-full h-auto rounded-lg"
|
||||
src={person.thumbnailPath}
|
||||
alt={person.name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-2 w-full flex justify-center items-center">
|
||||
<Badge variant={"secondary"} className="text-xs !font-medium font-mono">{person.assetCount} Assets</Badge>
|
||||
</div>
|
||||
@@ -108,8 +101,13 @@ export default function PersonItem({ person, onRemove }: IProps) {
|
||||
<Info size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="absolute top-2 right-2 ">
|
||||
<div className="absolute top-2 right-2 flex flex-col gap-2">
|
||||
<PersonMergeDropdown person={person} onRemove={onRemove}/>
|
||||
<Button variant="outline" className="!py-0.5 !px-2 text-xs h-7" onClick={() => {
|
||||
handleHide(!formData.isHidden);
|
||||
}}>
|
||||
Hide
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{!editMode ? (
|
||||
|
||||
@@ -56,6 +56,7 @@ export function PersonMergeDropdown({
|
||||
const [selectedPeople, setSelectedPeople] = useState<IPerson[]>([]);
|
||||
const [primaryPerson, setPrimaryPerson] = useState<IPerson>(person);
|
||||
const [merging, setMerging] = useState(false);
|
||||
const [threshold, setThreshold] = useState(0.5);
|
||||
const { toast } = useToast();
|
||||
|
||||
const selectedIds = useMemo(
|
||||
@@ -89,7 +90,7 @@ export function PersonMergeDropdown({
|
||||
};
|
||||
|
||||
const fetchSuggestions = () => {
|
||||
return listSimilarFaces(person.id)
|
||||
return listSimilarFaces(person.id, threshold)
|
||||
.then(setSimilarPeople)
|
||||
.catch(() => {
|
||||
toast({
|
||||
@@ -114,16 +115,14 @@ export function PersonMergeDropdown({
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleMerge = () => {
|
||||
if (selectedPeople.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const personIds = selectedPeople.map((p) => p.id);
|
||||
setMerging(true);
|
||||
|
||||
|
||||
const mergeInBatches = async (ids: string[], index: number = 0) => {
|
||||
if (index >= ids.length) {
|
||||
setOpen(false);
|
||||
@@ -132,10 +131,13 @@ export function PersonMergeDropdown({
|
||||
description: "People merged successfully",
|
||||
});
|
||||
setMerging(false);
|
||||
setSelectedPeople([]);
|
||||
setSimilarPeople([]);
|
||||
setSimilarLoading(true)
|
||||
onComplete?.(primaryPerson);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const batch = ids.slice(index, index + 5);
|
||||
try {
|
||||
await mergePerson(person.id, batch);
|
||||
@@ -148,7 +150,7 @@ export function PersonMergeDropdown({
|
||||
setMerging(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
mergeInBatches(personIds);
|
||||
};
|
||||
|
||||
@@ -161,6 +163,13 @@ export function PersonMergeDropdown({
|
||||
setSelectedPeople(newSelected);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSimilarLoading(true);
|
||||
fetchSuggestions();
|
||||
}
|
||||
}, [threshold, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !similarPeople.length) fetchSuggestions();
|
||||
}, [open, person.id, similarPeople]);
|
||||
@@ -227,7 +236,10 @@ export function PersonMergeDropdown({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className={cn("!py-0.5 !px-2 text-xs h-7", triggerClassName)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn("!py-0.5 !px-2 text-xs h-7", triggerClassName)}
|
||||
>
|
||||
Merge
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -279,6 +291,21 @@ export function PersonMergeDropdown({
|
||||
handleSearch(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor="threshold" className="block text-sm font-medium text-gray-300">
|
||||
Threshold
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
value={threshold}
|
||||
onChange={(e) => setThreshold(parseFloat(e.target.value))}
|
||||
placeholder="Threshold (0 to 1)"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedPeople.length > 0 ? (
|
||||
<div className="flex flex-nowrap overflow-x-auto gap-2 py-2">
|
||||
@@ -312,6 +339,15 @@ export function PersonMergeDropdown({
|
||||
|
||||
{renderContent()}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedPeople(similarPeople);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Select All
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IPerson } from "@/types/person";
|
||||
import { useConfig } from "@/contexts/ConfigContext";
|
||||
|
||||
interface FaceThumbnailProps {
|
||||
person: IPerson;
|
||||
@@ -8,6 +9,7 @@ interface FaceThumbnailProps {
|
||||
selected?: boolean;
|
||||
}
|
||||
const FaceThumbnail = ({ person, onSelect, selected }: FaceThumbnailProps) => {
|
||||
const { exImmichUrl } = useConfig();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -19,22 +21,27 @@ const FaceThumbnail = ({ person, onSelect, selected }: FaceThumbnailProps) => {
|
||||
onClick={() => onSelect(person)}
|
||||
>
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
src={person.thumbnailPath}
|
||||
alt={person.name}
|
||||
|
||||
className="w-full h-full rounded-lg"
|
||||
/>
|
||||
<p className="absolute text-[9px] top-1 right-1 bg-amber-300 px-1 text-zinc-900 rounded-lg group-hover:hidden">
|
||||
{person.similarity ? `${Math.round(person.similarity * 100)}%` : ""}
|
||||
</p>
|
||||
<Avatar
|
||||
src={person.thumbnailPath}
|
||||
alt={person.name}
|
||||
|
||||
className="w-full h-full rounded-lg"
|
||||
/>
|
||||
|
||||
<p className="absolute text-[9px] top-1 right-1 bg-amber-300 px-1 text-zinc-900 rounded-lg group-hover:hidden">
|
||||
{person.similarity ? `${Math.round(person.similarity * 100)}%` : ""}
|
||||
</p>
|
||||
</div>
|
||||
{person.name ? (
|
||||
<p className="text-[12px]">{person.name}</p>
|
||||
) : (
|
||||
<p className="text-[12px] italic text-gray-400">Untagged Person</p>
|
||||
)}
|
||||
|
||||
|
||||
{/* Make name or "Untagged Person" clickable to open the person's page */}
|
||||
<a
|
||||
href={`${exImmichUrl}/people/${person.id}`}
|
||||
className="text-[12px] cursor-pointer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{person.name ? person.name : <span className="italic text-gray-400">Untagged Person</span>}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,9 +27,14 @@ const DialogOverlay = React.forwardRef<
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
interface DialogContentProps extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
DialogContentProps
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
|
||||
@@ -39,6 +39,7 @@ export const mergePerson = (id: string, targetIds: string[]) => {
|
||||
return API.post(MERGE_PERSON_PATH(id), { ids: targetIds })
|
||||
}
|
||||
|
||||
export const listSimilarFaces = (id: string) => {
|
||||
return API.get(SIMILAR_FACES_PATH(id)).then((response) => response.map((person: any) => cleanUpPerson(person, true)));
|
||||
export const listSimilarFaces = (id: string, threshold: number) => {
|
||||
return API.get(SIMILAR_FACES_PATH(id), { threshold: threshold })
|
||||
.then((response) => response.map((person: any) => cleanUpPerson(person, true)));
|
||||
}
|
||||
@@ -31,7 +31,6 @@ export default async function handler(
|
||||
isNull(exif.latitude),
|
||||
isNotNull(assets.createdAt),
|
||||
isNotNull(exif.dateTimeOriginal),
|
||||
eq(assets.type, "VIDEO"),
|
||||
eq(assets.ownerId, currentUser.id),
|
||||
eq(assets.isVisible, true),
|
||||
))
|
||||
|
||||
@@ -18,6 +18,7 @@ interface IQuery {
|
||||
minimumAssetCount: number;
|
||||
sort: ISortField;
|
||||
sortOrder: "asc" | "desc";
|
||||
threshold?: number;
|
||||
}
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
@@ -26,9 +27,10 @@ export default async function handler(
|
||||
try {
|
||||
const {
|
||||
id,
|
||||
threshold = 0.5,
|
||||
} = req.query as any as IQuery;
|
||||
|
||||
const currentUser = await getCurrentUser(req);
|
||||
const currentUser = await getCurrentUser(req);
|
||||
const personRecords = await db
|
||||
.select()
|
||||
.from(person)
|
||||
@@ -92,7 +94,7 @@ export default async function handler(
|
||||
and(
|
||||
ne(person.id, id),
|
||||
eq(person.ownerId, currentUser.id),
|
||||
gt(similarity, 0.5)
|
||||
gt(similarity, threshold)
|
||||
)
|
||||
)
|
||||
.limit(12);
|
||||
|
||||
Reference in New Issue
Block a user