diff --git a/.github/workflows/pr-release.yml b/.github/workflows/pr-release.yml new file mode 100644 index 0000000..0cdede3 --- /dev/null +++ b/.github/workflows/pr-release.yml @@ -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 }} . diff --git a/package.json b/package.json index cb37978..7cf455a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/analytics/exif/AssetHeatMap.tsx b/src/components/analytics/exif/AssetHeatMap.tsx new file mode 100644 index 0000000..99178e2 --- /dev/null +++ b/src/components/analytics/exif/AssetHeatMap.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [weeksPerMonth, setWeeksPerMonth] = useState([]); // 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 ( +
+

Past Year

+ {loading ? ( +

Loading...

+ ) : ( +
+ + + + {weeksPerMonth.map((weeks, index) => ( + + ))} + + + + {heatMapData[0]?.map((_, rowIndex) => ( + + {heatMapData.map((column, colIndex) => ( + + ))} + + ))} + +
+ {months[index]} +
+ { + column[rowIndex]?.date ? ( + +
+ ) :
+ } +
+
+ )} +
+ ); +} diff --git a/src/components/ui/hover-card.tsx b/src/components/ui/hover-card.tsx new file mode 100644 index 0000000..863ff01 --- /dev/null +++ b/src/components/ui/hover-card.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + +)) +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName + +export { HoverCard, HoverCardTrigger, HoverCardContent } diff --git a/src/components/ui/linechardots.tsx b/src/components/ui/linechardots.tsx new file mode 100644 index 0000000..748b1c6 --- /dev/null +++ b/src/components/ui/linechardots.tsx @@ -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

Loading...

; + } + + if (errorMessage) { + return

{errorMessage}

; + } + + return ( + + + + {/* Added YAxis for the count */} + + + } + /> + + + chartConfig[value]?.label + } + /> + + + + ); +} diff --git a/src/components/ui/radarchartdpts.tsx b/src/components/ui/radarchartdpts.tsx new file mode 100644 index 0000000..51ddc39 --- /dev/null +++ b/src/components/ui/radarchartdpts.tsx @@ -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 ( + + + + + + } /> + + {/* Set grid color to white */} + ( + + )} + /> + + + + + ) +} diff --git a/src/config/routes.ts b/src/config/routes.ts index 1cb02e1..4bfe2f8 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -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"; \ No newline at end of file +export const GET_FILTERS = BASE_API_ENDPOINT + "/filters/asset-filters"; diff --git a/src/handlers/api/analytics.handler.ts b/src/handlers/api/analytics.handler.ts index 8215137..0daf406 100644 --- a/src/handlers/api/analytics.handler.ts +++ b/src/handlers/api/analytics.handler.ts @@ -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); +} \ No newline at end of file diff --git a/src/pages/analytics/exif.tsx b/src/pages/analytics/exif.tsx index fc944dd..64af274 100644 --- a/src/pages/analytics/exif.tsx +++ b/src/pages/analytics/exif.tsx @@ -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 ( -
+
+
+ {["Total", "Images", "Videos"].map((type, i) => ( + + + {type} + + + {loading ? "Loading..." : statistics[type.toLowerCase() as keyof typeof statistics].toLocaleString()} + + + ))} + + + Live Photos + + + {loading ? "Loading..." : livePhotoStatistics.total.toLocaleString()} + + +
+
{exifCharts.map((chart) => ( - ))} +
); diff --git a/src/pages/api/analytics/statistics/heatmap.tsx b/src/pages/api/analytics/statistics/heatmap.tsx new file mode 100644 index 0000000..d330b1a --- /dev/null +++ b/src/pages/api/analytics/statistics/heatmap.tsx @@ -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, + }); + } +} diff --git a/src/pages/api/analytics/statistics/livephoto.tsx b/src/pages/api/analytics/statistics/livephoto.tsx new file mode 100644 index 0000000..8eb670c --- /dev/null +++ b/src/pages/api/analytics/statistics/livephoto.tsx @@ -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, + }); + } +}