Compare commits
5 Commits
725c256791
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 224361f144 | |||
| 77b6755d05 | |||
| 2f25fd49be | |||
| 3891500cf2 | |||
| 062e89fcba |
5
public/color_logo.svg
Normal file
5
public/color_logo.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="81" height="58" viewBox="0 0 81 58" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.5332 0.0605059C16.6649 -0.107743 21.7192 0.126367 25.0234 0.14449L47.7461 0.166951L63.0156 0.155232C65.667 0.142338 69.1473 -0.0926585 71.7178 0.265584C76.9286 0.992005 80.2385 5.82745 80.2812 10.7988C80.3549 19.3927 80.2813 27.8445 80.25 36.373L80.2373 44.5009C80.2302 48.9661 80.5911 51.3666 77.3691 54.8593L77.2021 55.0253C73.9898 58.1924 70.8296 57.9125 66.626 57.8818C64.5719 49.5069 63.0354 41.4784 59.3477 33.5527C50.3209 14.1517 32.0588 6.98865 13.3232 0.0722246L13.5332 0.0605059ZM75.8604 43.2509C75.4023 40.7143 73.0029 39.0071 70.4561 39.4062C68.7552 39.6725 67.332 40.8392 66.7373 42.455C66.1426 44.0706 66.4703 45.882 67.5928 47.1875C68.7149 48.493 70.456 49.0884 72.1426 48.7431C74.6678 48.2261 76.3182 45.7875 75.8604 43.2509Z" fill="#372AAC"/>
|
||||
<path d="M8.58399 0.18042C10.3814 0.200991 14.9254 2.92742 16.5327 3.93417C21.7136 7.17947 27.0918 12.0437 28.3657 18.2976C30.6755 29.635 21.9976 44.5748 16.1342 53.8026C15.2109 55.1952 14.2582 56.5683 13.277 57.9207C10.5244 58.0604 8.0513 58.1593 5.53119 56.9057C3.13051 55.7166 1.30982 53.611 0.479608 51.0642C-0.20722 48.9684 0.0439338 40.9101 0.0485393 38.1189L0.0519124 24.9623L0.0267357 15.8015C-0.0432674 7.81256 -0.395124 2.77881 8.58399 0.18042Z" fill="#372AAC"/>
|
||||
<path d="M10.1708 0.152124C10.3676 0.150896 10.8088 0.156425 10.9774 0.206778C20.8807 3.15764 31.9872 8.71672 38.1975 17.0526C46.9371 28.783 44.7612 44.5228 42.7919 57.8759L40.5297 57.8706L35.0913 57.8884C38.1419 48.1759 41.6233 31.2005 37.655 21.569C32.838 9.87821 20.818 4.7284 10.1708 0.152124Z" fill="#372AAC"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
5
public/logo.svg
Normal file
5
public/logo.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="81" height="58" viewBox="0 0 81 58" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.5332 0.0605059C16.6649 -0.107743 21.7192 0.126367 25.0234 0.14449L47.7461 0.166951L63.0156 0.155232C65.667 0.142338 69.1473 -0.0926585 71.7178 0.265584C76.9286 0.992005 80.2385 5.82745 80.2812 10.7988C80.3549 19.3927 80.2813 27.8445 80.25 36.373L80.2373 44.5009C80.2302 48.9661 80.5911 51.3666 77.3691 54.8593L77.2021 55.0253C73.9898 58.1924 70.8296 57.9125 66.626 57.8818C64.5719 49.5069 63.0354 41.4784 59.3477 33.5527C50.3209 14.1517 32.0588 6.98865 13.3232 0.0722246L13.5332 0.0605059ZM75.8604 43.2509C75.4023 40.7143 73.0029 39.0071 70.4561 39.4062C68.7552 39.6725 67.332 40.8392 66.7373 42.455C66.1426 44.0706 66.4703 45.882 67.5928 47.1875C68.7149 48.493 70.456 49.0884 72.1426 48.7431C74.6678 48.2261 76.3182 45.7875 75.8604 43.2509Z" fill="white"/>
|
||||
<path d="M8.58399 0.18042C10.3814 0.200991 14.9254 2.92742 16.5327 3.93417C21.7136 7.17947 27.0918 12.0437 28.3657 18.2976C30.6755 29.635 21.9976 44.5748 16.1342 53.8025C15.2109 55.1952 14.2582 56.5683 13.277 57.9207C10.5244 58.0604 8.0513 58.1593 5.53119 56.9057C3.13051 55.7166 1.30982 53.611 0.479608 51.0641C-0.20722 48.9684 0.0439338 40.9101 0.0485393 38.1189L0.0519124 24.9623L0.0267357 15.8015C-0.0432674 7.81256 -0.395124 2.77881 8.58399 0.18042Z" fill="white"/>
|
||||
<path d="M10.1708 0.152124C10.3676 0.150896 10.8088 0.156425 10.9774 0.206778C20.8807 3.15764 31.9872 8.71672 38.1975 17.0526C46.9371 28.783 44.7612 44.5228 42.7919 57.8759L40.5297 57.8706L35.0913 57.8884C38.1419 48.1759 41.6233 31.2005 37.655 21.569C32.838 9.87821 20.818 4.7284 10.1708 0.152124Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -1,13 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ImageIcon,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { AlertTriangle, ChevronLeft, ChevronRight, Trash2 } from 'lucide-react';
|
||||
|
||||
import DetectionLocationMap from '@/components/map/detectionLocationMap';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -16,15 +10,12 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import AnnotatedDetectionImage from '@/components/video/annotatedDetectionImage';
|
||||
import { getDefectVisual } from '@/constants/defectVisualConfig';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DetectionRepairProof } from '@/types';
|
||||
|
||||
import {
|
||||
useDiscardDetectionMutation,
|
||||
useTicketClassDetectionsQuery,
|
||||
} from '../../hooks/useTicketQueries';
|
||||
import { DetectionDiscardDialog } from './DetectionDiscardDialog';
|
||||
import { RepairProofGallery } from './repair/RepairProofGallery';
|
||||
import { RepairSubmittedBanner } from './repair/RepairSubmittedBanner';
|
||||
|
||||
interface TicketClassDetectionPreviewProps {
|
||||
ticketId: string;
|
||||
@@ -63,44 +54,6 @@ function DetectionMetadataBar({
|
||||
);
|
||||
}
|
||||
|
||||
function DetectionRepairProofPanel({
|
||||
repairProof,
|
||||
}: {
|
||||
repairProof: DetectionRepairProof;
|
||||
}) {
|
||||
if (!repairProof.submitted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4 rounded-lg border bg-muted/20 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="size-5 text-primary" />
|
||||
<h4 className="font-semibold">Repair Proof</h4>
|
||||
</div>
|
||||
|
||||
{repairProof.submitted_at ? (
|
||||
<RepairSubmittedBanner
|
||||
submittedAt={repairProof.submitted_at}
|
||||
submitterName="Ticket Worker"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<RepairProofGallery
|
||||
imageUrls={repairProof.proof_image_urls}
|
||||
maxVisibleImages={4}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-semibold text-foreground">Repair Notes</p>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground">
|
||||
{repairProof.notes?.trim() || 'No repair notes provided.'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TicketClassDetectionPreviewSkeleton() {
|
||||
return (
|
||||
<>
|
||||
@@ -330,12 +283,6 @@ export function TicketClassDetectionPreview({
|
||||
]}
|
||||
/>
|
||||
|
||||
{activeDetection.repair_proof ? (
|
||||
<DetectionRepairProofPanel
|
||||
repairProof={activeDetection.repair_proof}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
interface RepairSubmittedBannerProps {
|
||||
submittedAt: string;
|
||||
submitterName: string;
|
||||
}
|
||||
|
||||
export function RepairSubmittedBanner({
|
||||
submittedAt,
|
||||
submitterName,
|
||||
}: RepairSubmittedBannerProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-3 dark:border-emerald-900/60 dark:bg-emerald-950/30">
|
||||
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="text-sm font-semibold text-emerald-800 dark:text-emerald-200">
|
||||
Repair Submitted
|
||||
</p>
|
||||
<p className="text-xs text-emerald-700/80 dark:text-emerald-300/80">
|
||||
{formatDate(submittedAt)} by {submitterName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,15 +58,17 @@ export default function TicketDetailPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="relative z-10 space-y-5">
|
||||
{overview ? (
|
||||
<TicketDetailHeader ticket={overview} />
|
||||
) : (
|
||||
<TicketOverviewHeaderSkeleton />
|
||||
)}
|
||||
<main className="relative z-10 space-y-5 xl:flex xl:h-[calc(100vh-6.5rem)] xl:min-h-0 xl:flex-col xl:gap-5 xl:space-y-0 xl:overflow-hidden">
|
||||
<div className="xl:shrink-0">
|
||||
{overview ? (
|
||||
<TicketDetailHeader ticket={overview} />
|
||||
) : (
|
||||
<TicketOverviewHeaderSkeleton />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)]">
|
||||
<div className="space-y-5">
|
||||
<div className="grid items-start gap-5 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,7fr)_minmax(0,3fr)] xl:items-stretch xl:overflow-hidden">
|
||||
<div className="space-y-5 xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
|
||||
{overview ? (
|
||||
<TicketOverviewCard ticket={overview} />
|
||||
) : (
|
||||
@@ -79,7 +81,7 @@ export default function TicketDetailPage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 self-start">
|
||||
<div className="space-y-3 self-start xl:min-h-0 xl:self-stretch xl:overflow-y-auto xl:overscroll-contain xl:pr-2">
|
||||
{isClassDetailLoading ? <TicketClassActionsSkeleton /> : null}
|
||||
{!isClassDetailLoading && classDetail ? (
|
||||
<TicketStatusActions ticket={classDetail} />
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Download,
|
||||
Eye,
|
||||
MoreVertical,
|
||||
PlayCircle,
|
||||
RotateCcw,
|
||||
Ticket,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { UploadListItem } from '@/types';
|
||||
|
||||
interface UploadActionsMenuProps {
|
||||
upload: UploadListItem;
|
||||
onViewVideo: (upload: UploadListItem) => void;
|
||||
}
|
||||
|
||||
export function UploadActionsMenu({
|
||||
upload,
|
||||
onViewVideo,
|
||||
}: UploadActionsMenuProps) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="size-4" />
|
||||
<span className="sr-only">Open actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{upload.raw_video_url ? (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onViewVideo(upload);
|
||||
}}
|
||||
>
|
||||
<PlayCircle className="size-4" />
|
||||
View Video
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem>
|
||||
<Eye /> View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<RotateCcw /> Retry Analysis
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!upload.ticket_id}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (upload.ticket_id) {
|
||||
router.push(`/ticket/${upload.ticket_id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Ticket /> Open Ticket
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<Trash2 /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
143
src/app/(modules)/upload/components/UploadDetailsDialog.tsx
Normal file
143
src/app/(modules)/upload/components/UploadDetailsDialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ExternalLink, Ticket, UserRound } from 'lucide-react';
|
||||
|
||||
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import type { UploadListItem } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { UploadResultCell } from './UploadResultCell';
|
||||
import { UploadStatusBadge } from './UploadStatusBadge';
|
||||
|
||||
interface UploadDetailsDialogProps {
|
||||
upload: UploadListItem | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function UploadDetailsDialog({
|
||||
upload,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: UploadDetailsDialogProps) {
|
||||
const { hasPermission } = usePermissions();
|
||||
const canOpenTicket = hasPermission(PERMISSIONS.TICKET.READ);
|
||||
const details = [
|
||||
{ label: 'Project', value: upload?.project },
|
||||
{ label: 'Package', value: upload?.package },
|
||||
{ label: 'Segment', value: upload?.segment },
|
||||
{ label: 'Chainage', value: upload?.chainage },
|
||||
{ label: 'Uploaded', value: formatDate(upload?.uploaded_at) },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-6xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload Details</DialogTitle>
|
||||
<DialogDescription>
|
||||
Preview the inspection video and review its processing details.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.7fr)_minmax(18rem,0.8fr)]">
|
||||
<section className="min-w-0 space-y-2">
|
||||
<p className="text-sm font-medium">Video Preview</p>
|
||||
<ProtectedVideoPlayer
|
||||
url={open ? upload?.raw_video_url : undefined}
|
||||
label={upload?.video_name}
|
||||
className="rounded-lg"
|
||||
unavailableTitle="Video preview unavailable"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<aside className="min-w-0 space-y-4 rounded-lg border bg-muted/15 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Status</p>
|
||||
<div className="mt-1.5">
|
||||
{upload ? (
|
||||
<UploadStatusBadge
|
||||
status={upload.status}
|
||||
progress={upload.progress}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-muted-foreground">Detections</p>
|
||||
<Badge variant="secondary" className="mt-1.5 tabular-nums">
|
||||
{upload?.total_detections ?? 0}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<p className="mb-2 text-xs text-muted-foreground">Result</p>
|
||||
{upload ? <UploadResultCell item={upload} /> : null}
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 border-t pt-4">
|
||||
{details.map((detail) => (
|
||||
<div
|
||||
key={detail.label}
|
||||
className={
|
||||
detail.label === 'Uploaded' ? 'col-span-2' : undefined
|
||||
}
|
||||
>
|
||||
<dt className="text-xs text-muted-foreground">
|
||||
{detail.label}
|
||||
</dt>
|
||||
<dd className="mt-1 truncate text-sm font-medium">
|
||||
{detail.value || '—'}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="flex min-w-0 items-start gap-2 border-t pt-4">
|
||||
<UserRound className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{upload?.uploaded_by.name || 'Unknown uploader'}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{upload?.uploaded_by.email || 'No email available'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter showCloseButton>
|
||||
{upload?.ticket_id && canOpenTicket ? (
|
||||
<Button asChild>
|
||||
<Link href={ROUTES.TICKET_DETAIL(upload.ticket_id)}>
|
||||
<Ticket />
|
||||
Open Ticket
|
||||
<ExternalLink />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { Badge } from '@/components/ui/badge';
|
||||
import type { UploadListItem } from '@/types';
|
||||
import { formatDate } from '@/utils/date';
|
||||
|
||||
import { UploadActionsMenu } from './UploadActionsMenu';
|
||||
import { UploadResultCell } from './UploadResultCell';
|
||||
import { UploadStatusBadge } from './UploadStatusBadge';
|
||||
|
||||
@@ -22,9 +21,7 @@ function resolveThumbnailUrl(value: string | null) {
|
||||
return baseUrl ? `${baseUrl}${path}` : path;
|
||||
}
|
||||
|
||||
export function useUploadListColumns(
|
||||
onViewVideo: (upload: UploadListItem) => void,
|
||||
): ColumnDef<UploadListItem>[] {
|
||||
export function useUploadListColumns(): ColumnDef<UploadListItem>[] {
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -133,24 +130,7 @@ export function useUploadListColumns(
|
||||
meta: { disableTruncate: true },
|
||||
cell: ({ row }) => <UploadResultCell item={row.original} />,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 64,
|
||||
minSize: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
meta: { disableTruncate: true },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-center">
|
||||
<UploadActionsMenu
|
||||
upload={row.original}
|
||||
onViewVideo={onViewVideo}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[onViewVideo],
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ interface UploadTableProps {
|
||||
skip: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
onUploadClick: (upload: UploadListItem) => void;
|
||||
onPageChange: (skip: number) => void;
|
||||
onLimitChange: (limit: number) => void;
|
||||
}
|
||||
@@ -23,6 +24,7 @@ export function UploadTable({
|
||||
skip,
|
||||
limit,
|
||||
total,
|
||||
onUploadClick,
|
||||
onPageChange,
|
||||
onLimitChange,
|
||||
}: UploadTableProps) {
|
||||
@@ -33,6 +35,7 @@ export function UploadTable({
|
||||
isLoading={isLoading}
|
||||
emptyTitle="No uploads found"
|
||||
emptyDescription="Upload your first road inspection video."
|
||||
onRowClick={onUploadClick}
|
||||
pagination={{
|
||||
skip,
|
||||
limit,
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { ProtectedVideoPlayer } from '@/components/media/ProtectedVideoPlayer';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { UploadListItem } from '@/types';
|
||||
|
||||
interface VideoPreviewDialogProps {
|
||||
upload: UploadListItem | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function VideoPreviewDialog({
|
||||
upload,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: VideoPreviewDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Video Preview</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<ProtectedVideoPlayer
|
||||
url={open ? upload?.raw_video_url : undefined}
|
||||
className="rounded-lg"
|
||||
unavailableTitle="Video preview unavailable"
|
||||
/>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import type { UploadListItem } from '@/types';
|
||||
import { CreateUploadDialog } from './components/CreateUploadDialog';
|
||||
import { useUploadListColumns } from './components/UploadListColumns';
|
||||
import { UploadTable } from './components/UploadTable';
|
||||
import { VideoPreviewDialog } from './components/VideoPreviewDialog';
|
||||
import { UploadDetailsDialog } from './components/UploadDetailsDialog';
|
||||
import { useUploadFilters } from './hooks/useUploadFilters';
|
||||
import { useUploadListEvents } from './hooks/useUploadListEvents';
|
||||
import { useUploadsQuery } from './hooks/useUploadQueries';
|
||||
@@ -20,13 +20,15 @@ export default function UploadPage() {
|
||||
const uploadsQuery = useUploadsQuery({ skip, limit });
|
||||
useUploadListEvents();
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [videoUpload, setVideoUpload] = useState<UploadListItem | null>(null);
|
||||
const [selectedUpload, setSelectedUpload] = useState<UploadListItem | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const uploads = uploadsQuery.data?.items ?? [];
|
||||
const total = uploadsQuery.data?.total ?? 0;
|
||||
const isLoading = uploadsQuery.isLoading;
|
||||
|
||||
const columns = useUploadListColumns(setVideoUpload);
|
||||
const columns = useUploadListColumns();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -50,17 +52,18 @@ export default function UploadPage() {
|
||||
skip={skip}
|
||||
limit={limit}
|
||||
total={total}
|
||||
onUploadClick={setSelectedUpload}
|
||||
onPageChange={setSkip}
|
||||
onLimitChange={setLimit}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<CreateUploadDialog open={isCreateOpen} onOpenChange={setIsCreateOpen} />
|
||||
<VideoPreviewDialog
|
||||
upload={videoUpload}
|
||||
open={Boolean(videoUpload)}
|
||||
<UploadDetailsDialog
|
||||
upload={selectedUpload}
|
||||
open={Boolean(selectedUpload)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setVideoUpload(null);
|
||||
if (!open) setSelectedUpload(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'size-8 rounded-lg border-2 p-0.5',
|
||||
md: 'size-12 rounded-lg border-2 p-1.5',
|
||||
lg: 'size-28 rounded-lg border-[5px] p-3',
|
||||
} as const;
|
||||
|
||||
const variantClasses = {
|
||||
default: 'border-white bg-primary shadow-md',
|
||||
solid: 'border-white bg-primary',
|
||||
glass: 'border-primary-foreground bg-primary/85 shadow-md',
|
||||
sidebar: 'border-0 bg-primary',
|
||||
} as const;
|
||||
|
||||
type LogoWrapperProps = {
|
||||
alt?: string;
|
||||
className?: string;
|
||||
imageClassName?: string;
|
||||
priority?: boolean;
|
||||
size?: keyof typeof sizeClasses;
|
||||
variant?: keyof typeof variantClasses;
|
||||
};
|
||||
|
||||
export function LogoWrapper({
|
||||
alt = 'RoadMonitor logo',
|
||||
className,
|
||||
imageClassName,
|
||||
priority = false,
|
||||
size = 'md',
|
||||
variant = 'default',
|
||||
}: LogoWrapperProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center justify-center',
|
||||
sizeClasses[size],
|
||||
variantClasses[variant],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt={alt}
|
||||
width={112}
|
||||
height={112}
|
||||
className={cn(
|
||||
'size-full object-contain brightness-0 invert',
|
||||
imageClassName,
|
||||
)}
|
||||
priority={priority}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
import * as React from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { LogoWrapper } from '@/components/LogoWrapper';
|
||||
import Image from 'next/image';
|
||||
import { NavUser } from '@/components/nav-user';
|
||||
import {
|
||||
filterMenuItems,
|
||||
@@ -55,7 +55,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
[authUser?.first_name, authUser?.last_name].filter(Boolean).join(' ') ||
|
||||
'Admin',
|
||||
email: authUser?.email || '',
|
||||
avatar: authUser?.profile_photo_url || '/avatars/profile.jpg',
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -97,10 +96,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
function SidebarBrandControl({ isMobile }: { isMobile: boolean }) {
|
||||
return (
|
||||
<div className="group/sidebar-brand relative flex size-8 shrink-0 items-center justify-center">
|
||||
<LogoWrapper
|
||||
size="sm"
|
||||
variant="sidebar"
|
||||
className="transition-opacity duration-150 group-data-[collapsible=icon]:group-hover/sidebar-brand:opacity-0 group-data-[collapsible=icon]:group-focus-within/sidebar-brand:opacity-0"
|
||||
<Image
|
||||
src="/color_logo.svg"
|
||||
alt="RoadMonitor logo"
|
||||
width={81}
|
||||
height={58}
|
||||
className="h-auto w-10 transition-opacity duration-150 group-data-[collapsible=icon]:group-hover/sidebar-brand:opacity-0 group-data-[collapsible=icon]:group-focus-within/sidebar-brand:opacity-0"
|
||||
priority
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LogoWrapper } from '@/components/LogoWrapper';
|
||||
import { cn } from '@/lib/utils';
|
||||
import Image from 'next/image';
|
||||
|
||||
const BOKEH_ORBS = [
|
||||
'top-[20%] left-[10%] size-[min(18vw,140px)] opacity-[0.14]',
|
||||
@@ -82,24 +82,21 @@ export function AuthBackground({ className }: AuthBackgroundProps) {
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="absolute inset-x-10 top-1/2 -translate-y-1/2 text-center text-primary-foreground">
|
||||
<div className="mb-9 inline-flex items-center gap-5 text-left">
|
||||
<LogoWrapper
|
||||
alt=""
|
||||
className="size-28 rounded-lg border-[5px] p-4"
|
||||
priority
|
||||
variant="glass"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-5xl font-semibold leading-none tracking-normal">
|
||||
Road
|
||||
</span>
|
||||
<span className="block text-5xl font-semibold leading-none tracking-normal">
|
||||
Monitor
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="mx-auto max-w-md text-base leading-7 text-primary-foreground/82">
|
||||
<Image
|
||||
src="/logo.svg"
|
||||
alt=""
|
||||
width={81}
|
||||
height={58}
|
||||
className="absolute left-10 top-12 h-auto w-16 object-contain"
|
||||
priority
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-10 left-10 right-10 text-left text-primary-foreground">
|
||||
<p className="text-3xl leading-none tracking-tight">
|
||||
<span className="font-semibold">Road</span>
|
||||
<span className="font-normal">Monitor</span>
|
||||
</p>
|
||||
<p className="mt-1 max-w-sm text-xs leading-4 text-primary-foreground/90">
|
||||
Map, monitor, and repair road issues with data-driven surface
|
||||
intelligence.
|
||||
</p>
|
||||
@@ -107,4 +104,3 @@ export function AuthBackground({ className }: AuthBackgroundProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,12 @@ import {
|
||||
CreditCard,
|
||||
LogOut,
|
||||
Sparkles,
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -39,7 +40,6 @@ export function NavUser({
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
};
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
@@ -76,9 +76,10 @@ export function NavUser({
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback>
|
||||
<UserRound className="size-4" aria-hidden="true" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{user.name}</span>
|
||||
@@ -95,9 +96,10 @@ export function NavUser({
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback>
|
||||
<UserRound className="size-4" aria-hidden="true" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{user.name}</span>
|
||||
|
||||
@@ -12,7 +12,7 @@ function Card({
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-lg bg-card py-(--card-spacing) text-sm text-card-foreground shadow-xs ring-1 ring-foreground/10 [--card-spacing:--spacing(6)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] *:[img:first-child]:rounded-lg *:[img:last-child]:rounded-lg",
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-lg bg-card py-(--card-spacing) text-sm text-card-foreground shadow-xs ring-1 ring-inset ring-foreground/10 [--card-spacing:--spacing(6)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] *:[img:first-child]:rounded-lg *:[img:last-child]:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user