feat: add new analytics endpoints and charts for enhanced data visualization
- Introduced new API routes for people names, geo photos, and album photos statistics. - Updated analytics handler to include functions for fetching new statistics. - Added new chart components for visualizing people names, geo photos, and album photos data in the Exif data analytics page. - Modified asset deletion function to accept an options parameter for forced deletion.
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { Card } from "@/components/ui/card";
|
||||
import PieChart, { IPieChartData } from "@/components/ui/pie-chart";
|
||||
import { getAlbumPhotosStatistics } from "@/handlers/api/analytics.handler";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
export default function AlbumPhotosChart() {
|
||||
const [chartData, setChartData] = useState<IPieChartData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAlbumPhotosStatistics();
|
||||
setChartData(data);
|
||||
} catch (error: any) {
|
||||
setErrorMessage(error?.message || "Failed to fetch album photos data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Album Distribution"
|
||||
description="Distribution of photos in and out of albums"
|
||||
>
|
||||
<PieChart
|
||||
data={chartData}
|
||||
loading={loading}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Card } from "@/components/ui/card";
|
||||
import PieChart, { IPieChartData } from "@/components/ui/pie-chart";
|
||||
import { getGeoPhotosStatistics } from "@/handlers/api/analytics.handler";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
export default function GeoPhotosChart() {
|
||||
const [chartData, setChartData] = useState<IPieChartData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getGeoPhotosStatistics();
|
||||
setChartData(data);
|
||||
} catch (error: any) {
|
||||
setErrorMessage(error?.message || "Failed to fetch geo photos data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Geo Data"
|
||||
description="Distribution of photos with and without geo data"
|
||||
>
|
||||
<PieChart
|
||||
data={chartData}
|
||||
loading={loading}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Card } from "@/components/ui/card";
|
||||
import PieChart, { IPieChartData } from "@/components/ui/pie-chart";
|
||||
import { getPeopleNamesStatistics } from "@/handlers/api/analytics.handler";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
export default function PeopleNamesChart() {
|
||||
const [chartData, setChartData] = useState<IPieChartData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getPeopleNamesStatistics();
|
||||
setChartData(data);
|
||||
} catch (error: any) {
|
||||
setErrorMessage(error?.message || "Failed to fetch people names data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="People Names"
|
||||
description="Distribution of people with and without names"
|
||||
>
|
||||
<PieChart
|
||||
data={chartData}
|
||||
loading={loading}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -59,6 +59,9 @@ export const SEARCH_PLACES_PATH = BASE_PROXY_ENDPOINT + "/search/places";
|
||||
export const ASSET_STATISTICS = BASE_PROXY_ENDPOINT + "/assets/statistics";
|
||||
export const LIVE_PHOTO_STATISTICS = BASE_API_ENDPOINT + "/analytics/statistics/livephoto";
|
||||
export const HEATMAP_DATA = BASE_API_ENDPOINT + "/analytics/statistics/heatmap";
|
||||
export const PEOPLE_NAMES_STATISTICS = BASE_API_ENDPOINT + "/analytics/statistics/people-names";
|
||||
export const GEO_PHOTOS_STATISTICS = BASE_API_ENDPOINT + "/analytics/statistics/geo-photos";
|
||||
export const ALBUM_PHOTOS_STATISTICS = BASE_API_ENDPOINT + "/analytics/statistics/album-photos";
|
||||
// Common
|
||||
export const GET_FILTERS = BASE_API_ENDPOINT + "/filters/asset-filters";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ASSET_STATISTICS, EXIF_DISTRIBUTION_PATH, HEATMAP_DATA, LIVE_PHOTO_STATISTICS } from "@/config/routes";
|
||||
import { ASSET_STATISTICS, EXIF_DISTRIBUTION_PATH, HEATMAP_DATA, LIVE_PHOTO_STATISTICS, PEOPLE_NAMES_STATISTICS, GEO_PHOTOS_STATISTICS, ALBUM_PHOTOS_STATISTICS } from "@/config/routes";
|
||||
import API from "@/lib/api";
|
||||
|
||||
export type ISupportedEXIFColumns =
|
||||
@@ -18,4 +18,16 @@ export const getLivePhotoStatistics = async () => {
|
||||
|
||||
export const getHeatMapData = async () => {
|
||||
return API.get(HEATMAP_DATA);
|
||||
}
|
||||
|
||||
export const getPeopleNamesStatistics = async () => {
|
||||
return API.get(PEOPLE_NAMES_STATISTICS);
|
||||
}
|
||||
|
||||
export const getGeoPhotosStatistics = async () => {
|
||||
return API.get(GEO_PHOTOS_STATISTICS);
|
||||
}
|
||||
|
||||
export const getAlbumPhotosStatistics = async () => {
|
||||
return API.get(ALBUM_PHOTOS_STATISTICS);
|
||||
}
|
||||
@@ -72,7 +72,7 @@ export const getAssetGeoHeatmap = async (filters: IHeatMapParams) => {
|
||||
return API.get(ASSET_GEO_HEATMAP_PATH, filters);
|
||||
}
|
||||
|
||||
export const deleteAssets = async (ids: string[]) => {
|
||||
export const deleteAssets = async (ids: string[], options: { force?: boolean } = { force: true }) => {
|
||||
return API.delete(UPDATE_ASSETS_PATH, { ids, force: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import AssetHeatMap from "@/components/analytics/exif/AssetHeatMap";
|
||||
import PeopleNamesChart from "@/components/analytics/exif/PeopleNamesChart";
|
||||
import GeoPhotosChart from "@/components/analytics/exif/GeoPhotosChart";
|
||||
import AlbumPhotosChart from "@/components/analytics/exif/AlbumPhotosChart";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getAssetStatistics, getLivePhotoStatistics } from "@/handlers/api/analytics.handler";
|
||||
import { humanizeBytes } from "@/helpers/string.helper";
|
||||
@@ -130,6 +133,14 @@ export default function ExifDataAnalytics() {
|
||||
</Card>
|
||||
</div>
|
||||
<AssetHeatMap />
|
||||
|
||||
{/* New Analytics Charts */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<PeopleNamesChart />
|
||||
<GeoPhotosChart />
|
||||
<AlbumPhotosChart />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{exifCharts.map((chart) => (
|
||||
<EXIFDistribution
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import { db } from "@/config/db";
|
||||
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
|
||||
import { assets, albumsAssetsAssets } from "@/schema";
|
||||
import { and, count, eq, sql } from "drizzle-orm";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
try {
|
||||
const currentUser = await getCurrentUser(req);
|
||||
|
||||
// Count photos that are in albums
|
||||
const inAlbums = await db.select({
|
||||
value: count(sql`DISTINCT ${assets.id}`),
|
||||
})
|
||||
.from(assets)
|
||||
.innerJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId))
|
||||
.where(eq(assets.ownerId, currentUser.id));
|
||||
|
||||
// Count total photos
|
||||
const totalPhotos = await db.select({
|
||||
value: count(),
|
||||
})
|
||||
.from(assets)
|
||||
.where(eq(assets.ownerId, currentUser.id));
|
||||
|
||||
const inAlbumsCount = inAlbums[0]?.value || 0;
|
||||
const totalCount = totalPhotos[0]?.value || 0;
|
||||
const notInAlbumsCount = totalCount - inAlbumsCount;
|
||||
|
||||
return res.status(200).json([
|
||||
{ label: "In Albums", value: inAlbumsCount },
|
||||
{ label: "Not in Albums", value: notInAlbumsCount }
|
||||
]);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
error: error?.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import { db } from "@/config/db";
|
||||
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
|
||||
import { assets, exif } from "@/schema";
|
||||
import { and, count, eq, isNotNull, isNull, sql } from "drizzle-orm";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
try {
|
||||
const currentUser = await getCurrentUser(req);
|
||||
|
||||
// Count photos with geo data (latitude and longitude not null)
|
||||
const withGeo = await db.select({
|
||||
value: count(),
|
||||
})
|
||||
.from(assets)
|
||||
.innerJoin(exif, eq(assets.id, exif.assetId))
|
||||
.where(and(
|
||||
eq(assets.ownerId, currentUser.id),
|
||||
isNotNull(exif.latitude),
|
||||
isNotNull(exif.longitude)
|
||||
));
|
||||
|
||||
// Count photos without geo data (latitude or longitude is null)
|
||||
const withoutGeo = await db.select({
|
||||
value: count(),
|
||||
})
|
||||
.from(assets)
|
||||
.leftJoin(exif, eq(assets.id, exif.assetId))
|
||||
.where(and(
|
||||
eq(assets.ownerId, currentUser.id),
|
||||
sql`(${exif.latitude} IS NULL OR ${exif.longitude} IS NULL)`
|
||||
));
|
||||
|
||||
return res.status(200).json([
|
||||
{ label: "With Geo Data", value: withGeo[0]?.value || 0 },
|
||||
{ label: "Without Geo Data", value: withoutGeo[0]?.value || 0 }
|
||||
]);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
error: error?.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import { db } from "@/config/db";
|
||||
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
|
||||
import { person } from "@/schema";
|
||||
import { and, count, eq, ne, sql } from "drizzle-orm";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
try {
|
||||
const currentUser = await getCurrentUser(req);
|
||||
|
||||
// Count people with names (non-empty names)
|
||||
const withNames = await db.select({
|
||||
value: count(),
|
||||
})
|
||||
.from(person)
|
||||
.where(and(
|
||||
eq(person.ownerId, currentUser.id),
|
||||
ne(person.name, ''),
|
||||
ne(person.name, 'Unknown')
|
||||
));
|
||||
|
||||
// Count people without names (empty or 'Unknown' names)
|
||||
const withoutNames = await db.select({
|
||||
value: count(),
|
||||
})
|
||||
.from(person)
|
||||
.where(and(
|
||||
eq(person.ownerId, currentUser.id),
|
||||
sql`(${person.name} = '' OR ${person.name} = 'Unknown')`
|
||||
));
|
||||
|
||||
return res.status(200).json([
|
||||
{ label: "With Names", value: withNames[0]?.value || 0 },
|
||||
{ label: "Without Names", value: withoutNames[0]?.value || 0 }
|
||||
]);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
error: error?.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -101,8 +101,8 @@ export default function EmptyVideosPage() {
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
return deleteAssets(contextState.selectedIds).then(() => {
|
||||
const handleDelete = (force?: boolean) => {
|
||||
return deleteAssets(contextState.selectedIds, { force }).then(() => {
|
||||
const newAssets = contextState.assets.filter((a) => !contextState.selectedIds.includes(a.id));
|
||||
setAssets(newAssets)
|
||||
contextState.updateContext({
|
||||
@@ -264,7 +264,7 @@ export default function EmptyVideosPage() {
|
||||
<AlertDialog
|
||||
title="Delete the selected videos?"
|
||||
description="This action will delete the selected videos and cannot be undone."
|
||||
onConfirm={handleDelete}
|
||||
onConfirm={() => handleDelete(true)}
|
||||
disabled={contextState.selectedIds.length === 0}
|
||||
>
|
||||
<Button variant={"destructive"} size={"sm"} disabled={contextState.selectedIds.length === 0}>
|
||||
|
||||
@@ -3,4 +3,5 @@ export { exif } from './exif.schema';
|
||||
export { person } from './person.schema';
|
||||
export { assetFaces } from './assetFaces.schema';
|
||||
export { users } from './users.schema';
|
||||
export { albumsAssetsAssets } from './albumAssetsAssets.schema';
|
||||
export * from './relationships';
|
||||
Reference in New Issue
Block a user