feat: added exif analytics
Signed-off-by: Varun Raj <mailme@varunraj.in>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1 +1,2 @@
|
||||
export { assets } from './asset.schema';
|
||||
export { assets } from './assets.schema';
|
||||
export { exif } from './exif.schema';
|
||||
+19
-1
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user