Merge pull request #58 from sai80082/ui-enhance

New Workflow for PR's and Heatmap for past one year assets
This commit is contained in:
Varun Raj Manoharan
2024-11-23 13:01:49 +05:30
committed by GitHub
11 changed files with 581 additions and 18 deletions
+32
View File
@@ -0,0 +1,32 @@
name: Build and Tag Docker Image on PR
on:
pull_request:
types: [opened, synchronize]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract PR number
id: pr-number
run: echo "PR_NUMBER=${{ github.event.number }}" >> $GITHUB_ENV
- name: Build and push Docker image
run: |
docker build --build-arg VERSION=pr-${{ env.PR_NUMBER }} \
-t ghcr.io/${{ github.repository }}:pr-${{ env.PR_NUMBER }} .
+3
View File
@@ -17,6 +17,7 @@
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-hover-card": "^1.1.1",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.1",
@@ -30,6 +31,7 @@
"@tanstack/react-table": "^8.20.1",
"@types/cookie": "^0.6.0",
"@types/qs": "^6.9.15",
"@types/react-calendar-heatmap": "^1.6.7",
"axios": "^1.7.4",
"chrono-node": "^1.4.9",
"class-variance-authority": "^0.7.0",
@@ -44,6 +46,7 @@
"pg": "^8.12.0",
"qs": "^6.13.0",
"react": "^18",
"react-calendar-heatmap": "^1.9.0",
"react-day-picker": "9.0.8",
"react-dom": "^18",
"react-grid-gallery": "^1.0.1",
@@ -0,0 +1,143 @@
import React, { useEffect, useState } from "react";
import { getHeatMapData } from "@/handlers/api/analytics.handler";
import { useConfig } from "@/contexts/ConfigContext";
type HeatMapEntry = {
date: string;
count: number;
};
export default function AssetHeatMap() {
const { exImmichUrl } = useConfig();
const [heatMapData, setHeatMapData] = useState<HeatMapEntry[][]>([]);
const [loading, setLoading] = useState(false);
const [weeksPerMonth, setWeeksPerMonth] = useState<number[]>([]); // Store weeks per month
const fetchHeatMapData = async () => {
setLoading(true);
try {
const data = await getHeatMapData();
setHeatMapData(formatHeatMapData(data));
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchHeatMapData();
}, []);
const currentDate = new Date();
const months: string[] = [];
// Build an array of the last 12 months
for (let i = 0; i < 12; i++) {
months.push(currentDate.toLocaleString("default", { month: "short" }));
currentDate.setMonth(currentDate.getMonth() - 1);
}
months.reverse();
// Flatten heatMapData to calculate min and max counts
const flattenedData = heatMapData.flat();
const minCount = Math.min(...flattenedData.map((entry) => entry.count));
const maxCount = Math.max(...flattenedData.map((entry) => entry.count));
// Check if a week contains a date with the first day of the month
const hasFirstDayOfMonth = (arr: HeatMapEntry[]) => {
return arr.some((entry) => new Date(entry.date).getDate() === 1);
};
const getColor = (count: number) => {
if (count === -1) return "";
// Calculate thresholds based on the data range
const range = maxCount - minCount;
const threshold1 = minCount + range * 0.2;
const threshold2 = minCount + range * 0.4;
const threshold3 = minCount + range * 0.6;
const threshold4 = minCount + range * 0.8;
if (count === 0) return "bg-zinc-800";
if (count <= threshold1) return "bg-green-200";
if (count <= threshold2) return "bg-green-400";
if (count <= threshold3) return "bg-green-500";
if (count <= threshold4) return "bg-green-600";
return "bg-green-800";
};
const formatHeatMapData = (data: HeatMapEntry[]) => {
const chunks = [];
const weeksPerMonthTemp: number[] = [];
let previousMonthIndex = 0;
for (let i = 0; i < data.length; i += 7) {
const thisWeek = data.slice(i, i + 7);
chunks.push(thisWeek);
if (hasFirstDayOfMonth(thisWeek)) {
const changeWeek = (i - previousMonthIndex) / 7;
previousMonthIndex = i;
weeksPerMonthTemp.push(changeWeek);
}
}
weeksPerMonthTemp.reverse();
setWeeksPerMonth(weeksPerMonthTemp);
return chunks;
};
return (
<div className="p-4 w-full">
<h2 className="text-xl font-bold mb-4">Past Year</h2>
{loading ? (
<p>Loading...</p>
) : (
<div className="overflow-x-auto">
<table className="table-auto border-separate border-spacing-1 w-full">
<thead>
<tr>
{weeksPerMonth.map((weeks, index) => (
<th
key={index}
className="text-center"
colSpan={weeks}
>
{months[index]}
</th>
))}
</tr>
</thead>
<tbody>
{heatMapData[0]?.map((_, rowIndex) => (
<tr key={rowIndex}>
{heatMapData.map((column, colIndex) => (
<td
key={colIndex}
className={`h-6 ${getColor(column[rowIndex]?.count ?? -1)} rounded-lg`}
style={{ width: `calc(${100 / 60}%)` }}
>
{
column[rowIndex]?.date ? (
<a
href={`${exImmichUrl}/search?query=%7B%22takenAfter%22%3A%22${column[rowIndex]?.date ?? "N/A"}T00%3A00%3A00.000Z%22%2C%22takenBefore%22%3A%22${column[rowIndex]?.date ?? "N/A"}T23%3A59%3A59.999Z%22%7D`}
className="block h-full w-full"
target="_blank"
>
<div
className="h-full w-full"
title={`Date: ${column[rowIndex]?.date ?? "N/A"}, Count: ${column[rowIndex]?.count ?? 0}`}
/>
</a>) : <div
className="h-full w-full"
/>
}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }
+100
View File
@@ -0,0 +1,100 @@
import { CartesianGrid, LabelList, Line, LineChart, XAxis, YAxis } from "recharts";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { useMemo } from "react";
import { CHART_COLORS } from "@/config/constants/chart.constant";
export interface ILineChartData {
label: string;
value: number;
}
interface ChartProps {
data: ILineChartData[];
topLabel?: string;
loading?: boolean;
errorMessage?: string | null;
}
export function LineChartDots({
data: _data,
topLabel,
loading,
errorMessage,
}: ChartProps) {
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">
<LineChart
accessibilityLayer
data={data}
>
<CartesianGrid vertical={false} />
<YAxis /> {/* Added YAxis for the count */}
<XAxis padding={{ left: 20 }} />
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent indicator="line" nameKey="label" hideLabel />
}
/>
<Line
dataKey="value"
type="natural"
stroke="#FF6B6B"
strokeWidth={2}
dot={{
fill: "var(--color-visitors)",
}}
activeDot={{
r: 6,
}}
>
<LabelList
position="top"
offset={12}
className="fill-foreground"
fontSize={12}
dataKey="label"
formatter={(value: keyof typeof chartConfig) =>
chartConfig[value]?.label
}
/>
</Line>
</LineChart>
</ChartContainer>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client"
import { TrendingUp } from "lucide-react"
import { PolarAngleAxis, PolarGrid, Radar, RadarChart } from "recharts"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import { useMemo } from "react"
import { CHART_COLORS } from "@/config/constants/chart.constant"
export const description = "A radar chart with dots"
export interface IRadarChartData {
label: string;
value: number;
color?: string; // Add color property here
}
interface ChartProps {
data: IRadarChartData[];
topLabel?: string;
loading?: boolean;
errorMessage?: string | null;
}
const chartConfig = {
label: {
label: "label",
color: "#FF0000",
},
} satisfies ChartConfig
export default function RadarChartDots({ data: _data, topLabel, loading, errorMessage }: ChartProps) {
const chartData = _data
return (
<Card>
<CardContent className="pb-0 border-none">
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[250px] border-none"
>
<RadarChart data={chartData}>
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
<PolarAngleAxis dataKey="label" />
<PolarGrid stroke="#FFFFFF" strokeDasharray="3 3" /> {/* Set grid color to white */}
<Radar
dataKey="value"
fill="#FF0000"
fillOpacity={0.8}
dot={({ cx, cy, payload }) => (
<circle
cx={cx}
cy={cy}
r={4}
fill={payload.color} // Set color dynamically
fillOpacity={1}
/>
)}
/>
</RadarChart>
</ChartContainer>
</CardContent>
</Card>
)
}
+6 -1
View File
@@ -36,5 +36,10 @@ export const ASSET_VIDEO_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/vi
export const SEARCH_PLACES_PATH = BASE_PROXY_ENDPOINT + "/search/places";
//Analytics
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";
// Common
export const GET_FILTERS = BASE_API_ENDPOINT + "/filters/asset-filters";
export const GET_FILTERS = BASE_API_ENDPOINT + "/filters/asset-filters";
+13 -1
View File
@@ -1,4 +1,4 @@
import { EXIF_DISTRIBUTION_PATH } from "@/config/routes";
import { ASSET_STATISTICS, EXIF_DISTRIBUTION_PATH, HEATMAP_DATA, LIVE_PHOTO_STATISTICS } from "@/config/routes";
import API from "@/lib/api";
export type ISupportedEXIFColumns =
@@ -7,3 +7,15 @@ export type ISupportedEXIFColumns =
export const getExifDistribution = async (column: ISupportedEXIFColumns) => {
return API.get(EXIF_DISTRIBUTION_PATH(column));
};
export const getAssetStatistics = async () => {
return API.get(ASSET_STATISTICS);
}
export const getLivePhotoStatistics = async () => {
return API.get(LIVE_PHOTO_STATISTICS);
}
export const getHeatMapData = async () => {
return API.get(HEATMAP_DATA);
}
+79 -16
View File
@@ -1,7 +1,20 @@
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";
import EXIFDistribution, {
IEXIFDistributionProps,
} from "@/components/analytics/exif/EXIFDistribution";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import AssetHeatMap from "@/components/analytics/exif/AssetHeatMap";
import { useEffect, useState } from "react";
import { getAssetStatistics, getLivePhotoStatistics } from "@/handlers/api/analytics.handler";
const inter = Inter({ subsets: ["latin"] });
@@ -42,36 +55,86 @@ const exifCharts: IEXIFDistributionProps[] = [
description: "Distribution of ISO",
},
{
column: 'exposureTime',
title: 'Exposure Time',
description: 'Distribution of exposure time'
column: "exposureTime",
title: "Exposure Time",
description: "Distribution of exposure time",
},
{
column: 'lensModel',
title: 'Lens Model',
description: 'Distribution of lens model'
column: "lensModel",
title: "Lens Model",
description: "Distribution of lens model",
},
{
column: 'projectionType',
title: 'Projection Type',
description: 'Distribution of projection type'
}
]
column: "projectionType",
title: "Projection Type",
description: "Distribution of projection type",
},
];
export default function ExifDataAnalytics() {
const [statistics, setStatistics] = useState({ images: 0, videos: 0, total: 0 });
const [loading, setLoading] = useState(false);
const [livePhotoStatistics, setLivePhotoStatistics] = useState({ total: 0 });
const fetchLivePhotoStatistics = async () => {
setLoading(true);
try {
const data = await getLivePhotoStatistics();
const livePhotoData = Array.isArray(data) && data.length ? data[0].value : 0;
setLivePhotoStatistics({ total: livePhotoData });
} finally {
setLoading(false);
}
};
const fetchStatisticsData = async () => {
setLoading(true);
try {
const data = await getAssetStatistics();
setStatistics(data);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStatisticsData();
fetchLivePhotoStatistics();
}, []);
return (
<PageLayout>
<Header
leftComponent="Exif Data"
/>
<Header leftComponent="Exif Data" />
<div className="grid grid-cols-4 gap-4">
{["Total", "Images", "Videos"].map((type, i) => (
<Card key={i}>
<CardHeader>
<CardTitle className="text-center text-lg font-mono">{type}</CardTitle>
</CardHeader>
<CardContent className="text-center text-lg font-mono">
{loading ? "Loading..." : statistics[type.toLowerCase() as keyof typeof statistics].toLocaleString()}
</CardContent>
</Card>
))}
<Card>
<CardHeader>
<CardTitle className="text-center text-lg font-mono">Live Photos</CardTitle>
</CardHeader>
<CardContent className="text-center text-lg font-mono">
{loading ? "Loading..." : livePhotoStatistics.total.toLocaleString()}
</CardContent>
</Card>
</div>
<AssetHeatMap />
<div className="grid grid-cols-3 gap-4">
{exifCharts.map((chart) => (
<EXIFDistribution
<EXIFDistribution
key={chart.column}
column={chart.column}
title={chart.title}
description={chart.description}
/>
))}
</div>
</PageLayout>
);
@@ -0,0 +1,69 @@
// 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, desc, eq, gte, isNotNull, ne, sql, } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
// Helper function to format date as YYYY-MM-DD
function formatDate(date: Date) {
return date.toISOString().split('T')[0];
}
function fillMissingDates(data: any[]) {
const today = new Date();
// Set the start date as one year ago, excluding the current month in the previous year
const oneYearAgo = new Date(today.getFullYear() - 1, today.getMonth() + 1, 1); // First day of the next month last year
const existingDates = new Set(data.map(item => item.date));
const filledData = [];
// Iterate from today to one year ago, but skip the current month from last year
for (let d = new Date(today); d >= oneYearAgo; d.setDate(d.getDate() - 1)) {
const dateStr = formatDate(d);
// Skip dates in the current month of the previous year
if (d.getFullYear() === today.getFullYear() - 1 && d.getMonth() === today.getMonth()) {
continue;
}
if (existingDates.has(dateStr)) {
filledData.push(data.find(item => item.date === dateStr));
} else {
filledData.push({ date: dateStr, count: 0 });
}
}
// Sort the filled data in descending order
filledData.sort((a, b) => a.date.localeCompare(b.date));
return filledData;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
const currentUser = await getCurrentUser(req);
const dataFromDB = await db.select({
date: sql`DATE(${assets.fileCreatedAt})`.as('date'),
count: count(),
})
.from(assets)
.where(
and(
eq(assets.ownerId, currentUser.id),
gte(assets.fileCreatedAt, sql`CURRENT_DATE - INTERVAL '1 YEAR'`))
)
.groupBy(sql`DATE(${assets.fileCreatedAt})`)
.orderBy(sql`DATE(${assets.fileCreatedAt}) DESC`)
const updatedData = fillMissingDates(dataFromDB);
return res.status(200).json(updatedData);
} catch (error: any) {
res.status(500).json({
error: error?.message,
});
}
}
@@ -0,0 +1,31 @@
// 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 { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { assets, exif } from "@/schema";
import { and, count, desc, eq, isNotNull, ne } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
const currentUser = await getCurrentUser(req);
const dataFromDB = await db.select({
value: count(),
})
.from(assets)
.where(and(
eq(assets.ownerId, currentUser.id),
isNotNull(assets.livePhotoVideoId))
);
return res.status(200).json(dataFromDB);
} catch (error: any) {
res.status(500).json({
error: error?.message,
});
}
}