diff --git a/src/config/constants/sidebarNavs.tsx b/src/config/constants/sidebarNavs.tsx index ebe8d2a..b4de6fc 100644 --- a/src/config/constants/sidebarNavs.tsx +++ b/src/config/constants/sidebarNavs.tsx @@ -1,4 +1,4 @@ -import { GalleryHorizontal, GalleryVerticalEnd, Image as ImageIcon, MapPin, MapPinX, Rewind, Search, User } from "lucide-react"; +import { GalleryHorizontal, GalleryVerticalEnd, Image as ImageIcon, MapPin, MapPinX, Rewind, Search, User, Video } from "lucide-react"; export const sidebarNavs = [ // { @@ -41,6 +41,12 @@ export const sidebarNavs = [ title: "Geo Heatmap", link: "/assets/geo-heatmap", icon: , + }, + // Empty Videos + { + title: "Empty Videos", + link: "/assets/empty-videos", + icon: , } ]; diff --git a/src/config/routes.ts b/src/config/routes.ts index dabca57..3a3aec3 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -38,6 +38,9 @@ export const ASSET_THUMBNAIL_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asse export const ASSET_PREVIEW_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/thumbnail/" + id + "?size=preview"; export const ASSET_VIDEO_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/asset/video/" + id; export const ASSET_GEO_HEATMAP_PATH = BASE_API_ENDPOINT + "/assets/geo-heatmap"; +export const LIST_EMPTY_VIDEOS_PATH = BASE_API_ENDPOINT + "/assets/empty-videos"; + + export const ASSET_SHARE_THUMBNAIL_PATH = ( { id, size, token, isPeople }: { id: string, size: string, token: string, isPeople: boolean } diff --git a/src/handlers/api/asset.handler.ts b/src/handlers/api/asset.handler.ts index 13ba4a2..ab0f686 100644 --- a/src/handlers/api/asset.handler.ts +++ b/src/handlers/api/asset.handler.ts @@ -1,5 +1,6 @@ import { ASSET_GEO_HEATMAP_PATH, + LIST_EMPTY_VIDEOS_PATH, FIND_ASSETS, LIST_MISSING_LOCATION_ALBUMS_PATH, LIST_MISSING_LOCATION_ASSETS_PATH, @@ -71,4 +72,16 @@ export const getAssetGeoHeatmap = async (filters: IHeatMapParams) => { export const deleteAssets = async (ids: string[]) => { return API.delete(UPDATE_ASSETS_PATH, { ids }); -} \ No newline at end of file +} + +export interface IEmptyVideosParams { + limit: number; + page: number; + maxDuration: number; + sortBy?: string; + sortOrder?: string; +} + +export const listEmptyVideos = async (filters: IEmptyVideosParams) => { + return API.get(LIST_EMPTY_VIDEOS_PATH, filters).then((assets) => assets.map(cleanUpAsset)); +} \ No newline at end of file diff --git a/src/pages/api/assets/empty-videos.ts b/src/pages/api/assets/empty-videos.ts new file mode 100644 index 0000000..e97d877 --- /dev/null +++ b/src/pages/api/assets/empty-videos.ts @@ -0,0 +1,109 @@ +import { db } from "@/config/db"; +import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; +import { NextApiRequest, NextApiResponse } from "next"; +import { and, eq, isNotNull, sql, desc, asc } from "drizzle-orm"; +import { assets } from "@/schema/assets.schema"; +import { exif } from "@/schema"; +import { isFlipped } from "@/helpers/asset.helper"; +import { ASSET_VIDEO_PATH } from "@/config/routes"; + + + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const currentUser = await getCurrentUser(req); + const { + limit = 10, + page = 1, + maxDuration = 2, + sortBy = 'localDateTime', + sortOrder = 'desc', + } = req.query as unknown as { + limit: string | number; + page: string | number; + maxDuration: string | number; + sortBy: string; + sortOrder: string; + }; + + // Convert to numbers + const limitNum = typeof limit === 'string' ? parseInt(limit, 10) : limit; + const pageNum = typeof page === 'string' ? parseInt(page, 10) : page; + const maxDurationNum = typeof maxDuration === 'string' ? parseFloat(maxDuration) : maxDuration; + + + if (!currentUser) { + return res.status(401).json({ message: "Unauthorized" }); + } + + + const durationExpr = sql` + ( + CAST(SUBSTRING(${assets.duration}, 1, 2) AS DECIMAL) * 3600 + + CAST(SUBSTRING(${assets.duration}, 4, 2) AS DECIMAL) * 60 + + CAST(SUBSTRING(${assets.duration}, 7, 2) AS DECIMAL) + + CAST(SUBSTRING(${assets.duration}, 10, 3) AS DECIMAL) / 1000 + ) +`; + +const dbAssets = await db + .selectDistinctOn([assets.id], { + id: assets.id, + deviceId: assets.deviceId, + type: assets.type, + originalPath: assets.originalPath, + isFavorite: assets.isFavorite, + duration: assets.duration, + encodedVideoPath: assets.encodedVideoPath, + originalFileName: assets.originalFileName, + sidecarPath: assets.sidecarPath, + deletedAt: assets.deletedAt, + localDateTime: assets.localDateTime, + exifImageWidth: exif.exifImageWidth, + exifImageHeight: exif.exifImageHeight, + ownerId: assets.ownerId, + dateTimeOriginal: exif.dateTimeOriginal, + orientation: exif.orientation, + durationSeconds: durationExpr.as('duration_seconds'), + }) + .from(assets) + .leftJoin(exif, eq(assets.id, exif.assetId)) + .where( + and( + eq(assets.ownerId, currentUser.id), + eq(assets.type, "VIDEO"), + eq(assets.visibility, "timeline"), + eq(assets.status, "active"), + isNotNull(assets.duration), + maxDurationNum > 0 ? sql`${durationExpr} < ${maxDurationNum}` : undefined + ) + ) + .orderBy( + assets.id, + sortBy === "duration" + ? (sortOrder === "desc" ? desc(durationExpr) : asc(durationExpr)) + : (sortOrder === "desc" + ? desc(assets.localDateTime) + : asc(assets.localDateTime)) + ) + .limit(limitNum) + .offset((pageNum - 1) * limitNum); + + const cleanedAssets = dbAssets.map((asset) => { + return { + ...asset, + exifImageHeight: isFlipped(asset?.orientation) + ? asset?.exifImageWidth + : asset?.exifImageHeight, + exifImageWidth: isFlipped(asset?.orientation) + ? asset?.exifImageHeight + : asset?.exifImageWidth, + orientation: asset?.orientation, + downloadUrl: asset?.id ? ASSET_VIDEO_PATH(asset.id) : null, + }; + }); + + return res.status(200).json(cleanedAssets); +} diff --git a/src/pages/assets/empty-videos.tsx b/src/pages/assets/empty-videos.tsx new file mode 100644 index 0000000..5daae3b --- /dev/null +++ b/src/pages/assets/empty-videos.tsx @@ -0,0 +1,281 @@ +import PageLayout from '@/components/layouts/PageLayout' +import Header from '@/components/shared/Header' +import Loader from '@/components/ui/loader' +import { useConfig } from '@/contexts/ConfigContext' +import { listEmptyVideos, deleteAssets } from '@/handlers/api/asset.handler' +import { IAsset } from '@/types/asset' +import React, { useEffect, useState, useMemo, useRef } from 'react' +import { useRouter } from 'next/router' +import { Camera, Trash2 } from 'lucide-react' +import { humanizeNumber } from '@/helpers/string.helper' +import PhotoSelectionContext, { IPhotoSelectionContext } from '@/contexts/PhotoSelectionContext' +import FloatingBar from '@/components/shared/FloatingBar' +import { Button } from '@/components/ui/button' +import { AlertDialog } from '@/components/ui/alert-dialog' +import AssetGrid from '@/components/shared/AssetGrid' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' + +export default function EmptyVideosPage() { + const router = useRouter() + const [assets, setAssets] = useState([]) + const [loading, setLoading] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + + // Initialize filters from URL params or defaults + const getInitialFilters = () => { + const { maxDuration, sortBy, sortOrder, page, limit } = router.query + return { + limit: typeof limit === 'string' ? parseInt(limit, 10) : 100, + page: typeof page === 'string' ? parseInt(page, 10) : 1, + maxDuration: typeof maxDuration === 'string' ? parseFloat(maxDuration) : 2, + sortBy: typeof sortBy === 'string' ? sortBy : 'localDateTime', + sortOrder: typeof sortOrder === 'string' ? sortOrder : 'desc' + } + } + + const [filters, setFilters] = useState(getInitialFilters) + + // Update URL with current filters + const updateURL = (newFilters: typeof filters) => { + const params = new URLSearchParams() + if (newFilters.maxDuration > 0) params.set('maxDuration', newFilters.maxDuration.toString()) + if (newFilters.sortBy !== 'localDateTime') params.set('sortBy', newFilters.sortBy) + if (newFilters.sortOrder !== 'desc') params.set('sortOrder', newFilters.sortOrder) + if (newFilters.page > 1) params.set('page', newFilters.page.toString()) + if (newFilters.limit !== 100) params.set('limit', newFilters.limit.toString()) + + const newURL = params.toString() ? `?${params.toString()}` : '' + router.push(`${router.pathname}${newURL}`, undefined, { shallow: true }) + } + + // PhotoSelection context state + const [contextState, setContextState] = useState({ + selectedIds: [], + assets: [], + config: { + albumId: '', + sort: "fileOriginalDate", + sortOrder: "asc" + }, + updateContext: (newConfig: Partial) => { + setContextState(prevState => ({ + ...prevState, + ...newConfig, + config: newConfig.config ? { ...prevState.config, ...newConfig.config } : prevState.config + })); + } + }); + + const selectedAssets = useMemo(() => + contextState.assets.filter((a) => contextState.selectedIds.includes(a.id)), + [contextState.assets, contextState.selectedIds] + ); + + const fetchEmptyVideos = async () => { + setLoading(true) + listEmptyVideos(filters) + .then((fetchedAssets) => { + setAssets(fetchedAssets) + contextState.updateContext({ + assets: fetchedAssets + }) + }) + .catch((error) => { + setErrorMessage(error.message) + }) + .finally(() => { + setLoading(false) + }) + } + + useEffect(() => { + fetchEmptyVideos() + }, [filters]) + + const handleSelectionChange = (selectedIds: string[]) => { + contextState.updateContext({ + selectedIds: selectedIds + }) + } + + const handleDelete = () => { + return deleteAssets(contextState.selectedIds).then(() => { + const newAssets = contextState.assets.filter((a) => !contextState.selectedIds.includes(a.id)); + setAssets(newAssets) + contextState.updateContext({ + selectedIds: [], + assets: newAssets, + }); + }) + } + + const [durationInputValue, setDurationInputValue] = useState(filters.maxDuration === 0 ? '' : filters.maxDuration.toString()) + + // Update filters when URL changes + useEffect(() => { + if (router.isReady) { + const newFilters = getInitialFilters() + setFilters(newFilters) + setDurationInputValue(newFilters.maxDuration === 0 ? '' : newFilters.maxDuration.toString()) + } + }, [router.isReady, router.query]) + + const handleMaxDurationChange = (value: string) => { + setDurationInputValue(value) + } + + const handleMaxDurationBlur = () => { + if (durationInputValue === '') { + const newFilters = { ...filters, maxDuration: 0, page: 1 } + setFilters(newFilters) + updateURL(newFilters) + return + } + + const numValue = parseFloat(durationInputValue) + if (isNaN(numValue) || numValue < 0.1) { + // Reset to previous valid value + setDurationInputValue(filters.maxDuration === 0 ? '' : filters.maxDuration.toString()) + return + } + + const newFilters = { ...filters, maxDuration: numValue, page: 1 } + setFilters(newFilters) + updateURL(newFilters) + } + + const handleSortChange = (sortBy: string) => { + const newFilters = { ...filters, sortBy, page: 1 } + setFilters(newFilters) + updateURL(newFilters) + } + + const handleSortOrderChange = (sortOrder: string) => { + const newFilters = { ...filters, sortOrder, page: 1 } + setFilters(newFilters) + updateURL(newFilters) + } + + const renderContent = () => { + if (loading) { + return + } + else if (errorMessage) { + return {errorMessage} + } + return ( + + {assets.length === 0 ? ( + + No empty videos found with the current filters. + + ) : ( + + )} + + ) + } + + return ( + + + {/* Duration Filter */} + + + Max Duration: + + handleMaxDurationChange(e.target.value)} + onBlur={handleMaxDurationBlur} + className="w-20 h-8" + /> + s + + + + + {/* Asset Count */} + + {humanizeNumber(assets.length)} + {' '} + + + + ) + } + /> + + {renderContent()} + {selectedAssets.length > 0 && + + + + {contextState.selectedIds.length} Selected + + + {contextState.selectedIds.length === contextState.assets.length ? ( + + contextState.updateContext({ + selectedIds: [], + }) + } + > + Unselect all + + ) : ( + + contextState.updateContext({ + selectedIds: contextState.assets.map((a) => a.id), + }) + } + > + Select all + + )} + + + + + Delete + + + + + + } + + + ) +}
+ {contextState.selectedIds.length} Selected +