feat: added exif analytics

Signed-off-by: Varun Raj <mailme@varunraj.in>
This commit is contained in:
Varun Raj
2024-08-18 22:28:01 +05:30
parent 3d833c48f5
commit 4642187066
17 changed files with 759 additions and 33 deletions
BIN
View File
Binary file not shown.
+1
View File
@@ -39,6 +39,7 @@
"react": "^18",
"react-day-picker": "9.0.8",
"react-dom": "^18",
"recharts": "^2.12.7",
"sass": "^1.77.8",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7"
@@ -0,0 +1,38 @@
import { Card } from "@/components/ui/card";
import PieChart, { IPieChartData } from "@/components/ui/pie-chart";
import { getExifDistribution, ISupportedEXIFColumns } from "@/handlers/api/analytics.handler";
import React, { useEffect, useState } from "react";
export interface IEXIFDistributionProps {
column: ISupportedEXIFColumns;
title: string;
description: string;
}
export default function EXIFDistribution(
{ column, title, description }: IEXIFDistributionProps
) {
const [chartData, setChartData] = useState<IPieChartData[]>([]);
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const fetchData = async () => {
return getExifDistribution(column)
.then(setChartData)
.finally(() => setLoading(false));
};
useEffect(() => {
fetchData();
}, [column]);
return (
<Card
title={title}
description={description}
>
<PieChart data={chartData} loading={loading} errorMessage={errorMessage} />
</Card>
);
}
+22 -3
View File
@@ -2,7 +2,7 @@ import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
const CardRoot = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
@@ -15,7 +15,7 @@ const Card = React.forwardRef<
{...props}
/>
))
Card.displayName = "Card"
CardRoot.displayName = "CardRoot"
const CardHeader = React.forwardRef<
HTMLDivElement,
@@ -73,4 +73,23 @@ const CardFooter = React.forwardRef<
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
interface CardProps {
title?: string
description?: string
children: React.ReactNode
}
const Card = ({ children, title, description }: CardProps) => {
return (
<CardRoot>
{!!(title || description) && (
<CardHeader>
{title && <CardTitle>{title}</CardTitle>}
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
)}
<CardContent>{children}</CardContent>
</CardRoot>
)
}
export { Card, CardRoot, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+368
View File
@@ -0,0 +1,368 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import {
NameType,
Payload,
ValueType,
} from "recharts/types/component/DefaultTooltipContent"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
+93
View File
@@ -0,0 +1,93 @@
import { CHART_COLORS, PIE_CONFIG } from "@/config/constants/chart.constant";
import React, { useMemo } from "react";
import { Label, Pie, PieChart as PieChartRoot } from "recharts";
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "./chart";
import { int } from "drizzle-orm/mysql-core";
export interface IPieChartData { label: string; value: number }
interface ChartProps {
data: IPieChartData[];
topLabel?: string;
loading?: boolean;
errorMessage?: string | null;
}
export default function PieChart({ data: _data, topLabel, loading, errorMessage }: ChartProps) {
const topItem = _data[0];
const data = useMemo(() => _data.map((item, index) => ({
...item,
fill: CHART_COLORS[index] || "#000000",
})), [_data]);
const chartConfig = useMemo(() => {
let config: ChartConfig = {};
data.map((data) => {
config[data.label as string] = {
label: data.label,
color: "hsl(var(--chart-1))",
};
});
return config;
}, [data]);
if (loading) {
return <p>Loading...</p>;
}
if (errorMessage) {
return <p>{errorMessage}</p>;
}
return (
<ChartContainer config={chartConfig} className="min-h-[200px] w-full">
<PieChartRoot accessibilityLayer>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
<Pie
dataKey="value"
data={data}
nameKey="label"
radius={PIE_CONFIG.radius}
innerRadius={PIE_CONFIG.innerRadius}
strokeWidth={PIE_CONFIG.strokeWidth}
>
{topItem && (
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={viewBox.cy}
className="fill-foreground text-3xl font-bold"
>
{topItem.value.toLocaleString()}
</tspan>
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) + 24}
className="fill-muted-foreground"
>
{topLabel || `(${topItem.label})`}
</tspan>
</text>
);
}
}}
/>
)}
</Pie>
</PieChartRoot>
</ChartContainer>
);
}
+28
View File
@@ -0,0 +1,28 @@
export const CHART_COLORS = [
"#FF6B6B",
"#4ECDC4",
"#45B7D1",
"#FFA07A",
"#98D8C8",
"#F7DC6F",
"#BB8FCE",
"#F1948A",
"#85C1E9",
"#82E0AA",
"#F8C471",
"#D7BDE2",
"#F1C40F",
"#3498DB",
"#E74C3C",
"#2ECC71",
"#9B59B6",
"#1ABC9C",
"#D35400",
"#34495E"
]
export const PIE_CONFIG = {
innerRadius: 60,
radius: 10,
strokeWidth: 5
}
+6 -1
View File
@@ -1,4 +1,4 @@
import { Home, User } from "lucide-react";
import { Home, Image, User } from "lucide-react";
export const sidebarNavs = [
@@ -7,6 +7,11 @@ export const sidebarNavs = [
link: "/",
icon: <User className="h-4 w-4" />,
},
{
title: "Exif Analytics",
link: "/analytics/exif",
icon: <Image className="h-4 w-4" />,
},
{
title: "Statistics",
link: "/statistics",
+10 -6
View File
@@ -1,7 +1,11 @@
export const BASE_ENDPOINT = '/api/immich-proxy';
export const BASE_API_ENDPOINT = '/api';
export const BASE_PROXY_ENDPOINT = BASE_API_ENDPOINT + '/immich-proxy';
export const LIST_PEOPLE_PATH = BASE_ENDPOINT + "/people";
export const SEARCH_PEOPLE_PATH = BASE_ENDPOINT + "/search/person";
export const PERSON_THUBNAIL_PATH = (id: string) => BASE_ENDPOINT + "/thumbnail/" + id;
export const UPDATE_PERSON_PATH = (id: string) => BASE_ENDPOINT + "/people/" + id;
export const MERGE_PERSON_PATH = (id: string) => BASE_ENDPOINT + "/people/" + id + "/merge";
export const LIST_PEOPLE_PATH = BASE_PROXY_ENDPOINT + "/people";
export const SEARCH_PEOPLE_PATH = BASE_PROXY_ENDPOINT + "/search/person";
export const PERSON_THUBNAIL_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/thumbnail/" + id;
export const UPDATE_PERSON_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/people/" + id;
export const MERGE_PERSON_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/people/" + id + "/merge";
export const EXIF_DISTRIBUTION_PATH = (column: string) => BASE_API_ENDPOINT + "/analytics/exif/" + column;
+9
View File
@@ -0,0 +1,9 @@
import { EXIF_DISTRIBUTION_PATH } from "@/config/routes";
import API from "@/lib/api";
export type ISupportedEXIFColumns =
"make" | "model" | "focal-length" | "city" | "state" | "country" | "iso" | "exposureTime" | 'lensModel' | "projectionType";
export const getExifDistribution = async (column: ISupportedEXIFColumns) => {
return API.get(EXIF_DISTRIBUTION_PATH(column));
};
+78
View File
@@ -0,0 +1,78 @@
import { Inter } from "next/font/google";
import PageLayout from "@/components/layouts/PageLayout";
import Header from "@/components/shared/Header";
import EXIFDistribution, { IEXIFDistributionProps } from "@/components/analytics/exif/EXIFDistribution";
const inter = Inter({ subsets: ["latin"] });
const exifCharts: IEXIFDistributionProps[] = [
{
column: "make",
title: "Make",
description: "Distribution of camera make",
},
{
column: "model",
title: "Model",
description: "Distribution of camera model",
},
{
column: "focal-length",
title: "Focal Length",
description: "Distribution of focal length",
},
{
column: "city",
title: "City",
description: "Distribution of city",
},
{
column: "state",
title: "State",
description: "Distribution of state",
},
{
column: "country",
title: "Country",
description: "Distribution of country",
},
{
column: "iso",
title: "ISO",
description: "Distribution of ISO",
},
{
column: 'exposureTime',
title: 'Exposure Time',
description: 'Distribution of exposure time'
},
{
column: 'lensModel',
title: 'Lens Model',
description: 'Distribution of lens model'
},
{
column: 'projectionType',
title: 'Projection Type',
description: 'Distribution of projection type'
}
]
export default function ExifDataAnalytics() {
return (
<PageLayout>
<Header
leftComponent="Exif Data"
/>
<div className="grid grid-cols-3 gap-4">
{exifCharts.map((chart) => (
<EXIFDistribution
key={chart.column}
column={chart.column}
title={chart.title}
description={chart.description}
/>
))}
</div>
</PageLayout>
);
}
@@ -0,0 +1,51 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { CHART_COLORS } from "@/config/constants/chart.constant";
import { db } from "@/config/db";
import { exif } from "@/schema";
import { count, desc, isNotNull, ne } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
const columnMap = {
make: exif.make,
model: exif.model,
'focal-length': exif.focalLength,
city: exif.city,
state: exif.state,
country: exif.country,
iso: exif.iso,
exposureTime: exif.exposureTime,
lensModel: exif.lensModel,
projectionType: exif.projectionType
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { property } = req.query as { property: string };
const column = columnMap[property as keyof typeof columnMap];
if (!column) {
return res.status(400).json({
name: "Error",
});
}
try {
const dataFromDB = await db.select({
value: count(),
label: column,
})
.from(exif)
.where(isNotNull(column))
.limit(20)
.groupBy(column).orderBy(desc(count()));
return res.status(200).json(dataFromDB);
} catch (error: any) {
res.status(500).json({
error: error?.message,
});
}
}
-21
View File
@@ -1,21 +0,0 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { db } from "@/config/db";
import { assets } from "@/schema";
import { count } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
const assetsFromDB = await db.select({
count: count()
}).from(assets).groupBy(assets.createdAt);
res.status(200).json(assetsFromDB);
} catch (error) {
res.status(500).json({
name: "Error",
});
}
}
+34
View File
@@ -0,0 +1,34 @@
import { pgTable, uuid, varchar, integer, bigint, timestamp, doublePrecision, text } from 'drizzle-orm/pg-core';
import { assets } from './assets.schema'; // Assuming you have an assets table defined
export const exif = pgTable('exif', {
assetId: uuid('assetId').primaryKey().notNull().references(() => assets.id, { onDelete: 'cascade' }),
make: varchar('make'),
model: varchar('model'),
exifImageWidth: integer('exifImageWidth'),
exifImageHeight: integer('exifImageHeight'),
fileSizeInByte: bigint('fileSizeInByte', { mode: 'number' }),
orientation: varchar('orientation'),
dateTimeOriginal: timestamp('dateTimeOriginal', { withTimezone: true }),
modifyDate: timestamp('modifyDate', { withTimezone: true }),
lensModel: varchar('lensModel'),
fNumber: doublePrecision('fNumber'),
focalLength: doublePrecision('focalLength'),
iso: integer('iso'),
latitude: doublePrecision('latitude'),
longitude: doublePrecision('longitude'),
city: varchar('city'),
state: varchar('state'),
country: varchar('country'),
description: text('description').notNull().default(''),
fps: doublePrecision('fps'),
exposureTime: varchar('exposureTime'),
livePhotoCID: varchar('livePhotoCID'),
timeZone: varchar('timeZone'),
projectionType: varchar('projectionType'),
profileDescription: varchar('profileDescription'),
colorspace: varchar('colorspace'),
bitsPerSample: integer('bitsPerSample'),
autoStackId: varchar('autoStackId'),
rating: integer('rating'),
});
+2 -1
View File
@@ -1 +1,2 @@
export { assets } from './asset.schema';
export { assets } from './assets.schema';
export { exif } from './exif.schema';
+19 -1
View File
@@ -68,4 +68,22 @@
body {
@apply bg-background text-foreground;
}
}
}
@layer base {
:root {
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}