feat: added empty videos option
Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
@@ -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: <MapPin className="h-4 w-4" />,
|
||||
},
|
||||
// Empty Videos
|
||||
{
|
||||
title: "Empty Videos",
|
||||
link: "/assets/empty-videos",
|
||||
icon: <Video className="h-4 w-4" />,
|
||||
}
|
||||
|
||||
];
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -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<number>`
|
||||
(
|
||||
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);
|
||||
}
|
||||
@@ -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<IAsset[]>([])
|
||||
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<IPhotoSelectionContext>({
|
||||
selectedIds: [],
|
||||
assets: [],
|
||||
config: {
|
||||
albumId: '',
|
||||
sort: "fileOriginalDate",
|
||||
sortOrder: "asc"
|
||||
},
|
||||
updateContext: (newConfig: Partial<IPhotoSelectionContext>) => {
|
||||
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 <Loader />
|
||||
}
|
||||
else if (errorMessage) {
|
||||
return <div className="text-red-500 p-4">{errorMessage}</div>
|
||||
}
|
||||
return (
|
||||
<div className="p-4">
|
||||
{assets.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No empty videos found with the current filters.
|
||||
</div>
|
||||
) : (
|
||||
<AssetGrid
|
||||
assets={assets}
|
||||
selectable={true}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout className="!p-0 !mb-0 relative">
|
||||
<Header
|
||||
leftComponent="Empty Videos"
|
||||
rightComponent={
|
||||
!loading && (
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Duration Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="maxDuration" className="text-sm font-medium whitespace-nowrap">
|
||||
Max Duration:
|
||||
</Label>
|
||||
<Input
|
||||
id="maxDuration"
|
||||
type="number"
|
||||
min="0.1"
|
||||
max="60"
|
||||
step="0.1"
|
||||
value={durationInputValue}
|
||||
onChange={(e) => handleMaxDurationChange(e.target.value)}
|
||||
onBlur={handleMaxDurationBlur}
|
||||
className="w-20 h-8"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">s</span>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Asset Count */}
|
||||
<div className="flex items-center gap-1 text-sm text-gray-700 dark:text-gray-500">
|
||||
{humanizeNumber(assets.length)}
|
||||
{' '}
|
||||
<Camera className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<PhotoSelectionContext.Provider
|
||||
value={{
|
||||
...contextState,
|
||||
updateContext: contextState.updateContext,
|
||||
}}
|
||||
>
|
||||
{renderContent()}
|
||||
{selectedAssets.length > 0 &&
|
||||
<FloatingBar>
|
||||
<div className="flex items-center gap-2 justify-between w-full">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{contextState.selectedIds.length} Selected
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{contextState.selectedIds.length === contextState.assets.length ? (
|
||||
<Button
|
||||
variant={"outline"}
|
||||
size={"sm"}
|
||||
onClick={() =>
|
||||
contextState.updateContext({
|
||||
selectedIds: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
Unselect all
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant={"outline"}
|
||||
size={"sm"}
|
||||
onClick={() =>
|
||||
contextState.updateContext({
|
||||
selectedIds: contextState.assets.map((a) => a.id),
|
||||
})
|
||||
}
|
||||
>
|
||||
Select all
|
||||
</Button>
|
||||
)}
|
||||
<div className="h-[10px] w-[1px] bg-zinc-500 dark:bg-zinc-600"></div>
|
||||
<AlertDialog
|
||||
title="Delete the selected videos?"
|
||||
description="This action will delete the selected videos and cannot be undone."
|
||||
onConfirm={handleDelete}
|
||||
disabled={contextState.selectedIds.length === 0}
|
||||
>
|
||||
<Button variant={"destructive"} size={"sm"} disabled={contextState.selectedIds.length === 0}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</FloatingBar>
|
||||
}
|
||||
</PhotoSelectionContext.Provider>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user