-
Notifications
You must be signed in to change notification settings - Fork 238
[FEAT] Upload images for failed device policy #2018
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
25ea26a
d7ddd4f
1b2e8c9
07bc56a
4bb6228
776ad11
685dfe8
84903c0
248c3c5
0352144
e87e795
919261c
4d767a6
82d074d
379506a
a4b50ad
0428a77
81ab24c
befa85c
dd1e5ed
09db683
32cebeb
f5c364c
0a46363
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import { Button } from '@comp/ui/button'; | ||
| import { ChevronLeft, ChevronRight } from 'lucide-react'; | ||
|
|
||
| interface CarouselControlsProps { | ||
| currentIndex: number; | ||
| total: number; | ||
| onPrevious: () => void; | ||
| onNext?: () => void; | ||
| } | ||
|
|
||
| export function CarouselControls({ | ||
| currentIndex, | ||
| total, | ||
| onPrevious, | ||
| onNext, | ||
| }: CarouselControlsProps) { | ||
| const isFirstVideo = currentIndex === 0; | ||
|
|
||
| return ( | ||
| <div className="flex items-center justify-between"> | ||
| <Button | ||
| variant="outline" | ||
| size="icon" | ||
| onClick={onPrevious} | ||
| disabled={isFirstVideo} | ||
| aria-label="Previous video" | ||
| > | ||
| <ChevronLeft className="h-4 w-4" /> | ||
| </Button> | ||
|
|
||
| <div className="text-muted-foreground text-sm"> | ||
| {currentIndex + 1} of {total} | ||
| </div> | ||
|
|
||
| <Button | ||
| variant="outline" | ||
| size="icon" | ||
| onClick={onNext} | ||
| disabled={!onNext} | ||
| aria-label="Next video" | ||
| > | ||
| <ChevronRight className="h-4 w-4" /> | ||
| </Button> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import useSWR from 'swr'; | ||
| import Image from 'next/image'; | ||
|
|
||
| const fetcher = async (key: string) => { | ||
| const res = await fetch(key, { cache: 'no-store' }); | ||
| if (!res.ok) { | ||
| throw new Error('Failed to fetch image url'); | ||
| } | ||
| const json = (await res.json()) as { url?: string }; | ||
| if (!json.url) { | ||
| throw new Error('No url returned'); | ||
| } | ||
| return json.url; | ||
| }; | ||
|
|
||
| export function PolicyImagePreview({ image }: { image: string }) { | ||
| const { data: signedUrl, error, isLoading } = useSWR( | ||
| () => (image ? `/api/get-image-url?key=${encodeURIComponent(image)}` : null), | ||
| fetcher, | ||
| ); | ||
|
|
||
| if (isLoading) { | ||
| return <div className="h-[500px] w-full flex items-center justify-center text-sm text-muted-foreground">Loading image...</div>; | ||
| } | ||
|
|
||
| if (error || !signedUrl) { | ||
| return <div className="h-[500px] w-full flex items-center justify-center text-sm text-muted-foreground">Failed to load image</div>; | ||
| } | ||
|
|
||
| return ( | ||
| <div className="overflow-hidden w-full h-[500px]"> | ||
| <Image | ||
| key={signedUrl} | ||
| src={signedUrl} | ||
| alt="Policy image" | ||
| width={800} | ||
| height={600} | ||
| className="h-full w-full object-contain" | ||
| /> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| 'use client'; | ||
|
|
||
| import { useEffect, useState } from 'react'; | ||
|
|
||
| import { | ||
| Dialog, | ||
| DialogContent, | ||
| DialogHeader, | ||
| DialogTitle, | ||
| } from '@comp/ui/dialog'; | ||
| import { CarouselControls } from './CarouselControls'; | ||
| import { PolicyImagePreview } from './PolicyImagePreview'; | ||
|
|
||
| interface PolicyImagePreviewModalProps { | ||
| open: boolean; | ||
| onOpenChange: (open: boolean) => void; | ||
| images: string[]; | ||
| } | ||
|
|
||
| export function PolicyImagePreviewModal({ open, images, onOpenChange }: PolicyImagePreviewModalProps) { | ||
| const [currentIndex, setCurrentIndex] = useState(0); | ||
|
|
||
| useEffect(() => { | ||
| if (open) { | ||
| setCurrentIndex(0); | ||
| } | ||
| }, [open, images.length]); | ||
|
|
||
| const hasImages = images.length > 0; | ||
|
|
||
| const goPrevious = () => { | ||
| setCurrentIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1)); | ||
| }; | ||
|
|
||
| const goNext = () => { | ||
| setCurrentIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1)); | ||
| }; | ||
|
|
||
| return ( | ||
| <Dialog open={open} onOpenChange={onOpenChange}> | ||
| <DialogContent> | ||
| <DialogHeader> | ||
| <DialogTitle>Preview</DialogTitle> | ||
| </DialogHeader> | ||
| <div className="space-y-4"> | ||
| {!hasImages && <p className="text-sm text-muted-foreground">No images to display.</p>} | ||
|
|
||
| {hasImages && ( | ||
| <> | ||
| <PolicyImagePreview image={images[currentIndex]} /> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing array bounds check in image preview modalLow Severity The |
||
| <CarouselControls | ||
| currentIndex={currentIndex} | ||
| total={images.length} | ||
| onPrevious={goPrevious} | ||
| onNext={goNext} | ||
| /> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Carousel navigation has inconsistent wrap-around behaviorLow Severity The Additional Locations (1) |
||
| </> | ||
| )} | ||
| </div> | ||
| </DialogContent> | ||
| </Dialog> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| 'use client'; | ||
|
|
||
| import { cn } from '@comp/ui/cn'; | ||
| import { CheckCircle2, Image as ImageIcon, MoreVertical, XCircle } from 'lucide-react'; | ||
| import { useMemo, useState } from 'react'; | ||
| import { | ||
| DropdownMenu, | ||
| DropdownMenuContent, | ||
| DropdownMenuItem, | ||
| DropdownMenuTrigger, | ||
| } from '@comp/ui/dropdown-menu'; | ||
|
|
||
| import { FleetPolicy } from "../types"; | ||
| import { Button } from '@comp/ui/button'; | ||
| import { PolicyImagePreviewModal } from './PolicyImagePreviewModal'; | ||
|
|
||
| interface PolicyItemProps { | ||
| policy: FleetPolicy; | ||
| } | ||
|
|
||
| export const PolicyItem = ({ policy }: PolicyItemProps) => { | ||
| const [isPreviewOpen, setIsPreviewOpen] = useState(false); | ||
| const [dropdownOpen, setDropdownOpen] = useState(false); | ||
|
|
||
| const actions = useMemo(() => { | ||
| if ((policy?.attachments || []).length > 0 && policy.response === 'pass') { | ||
| return [ | ||
| { | ||
| label: 'Preview images', | ||
| renderIcon: () => <ImageIcon className="h-4 w-4" />, | ||
| onClick: () => setIsPreviewOpen(true), | ||
| }, | ||
| ]; | ||
| } | ||
|
|
||
| return []; | ||
| }, [policy]); | ||
|
|
||
| return ( | ||
| <div | ||
| className={cn( | ||
| 'hover:bg-muted/50 flex items-center justify-between rounded-md border border-l-4 p-3 shadow-sm transition-colors', | ||
| policy.response === 'pass' ? 'border-l-primary' : 'border-l-red-500', | ||
| )} | ||
| > | ||
| <p className="font-medium">{policy.name}</p> | ||
| <div className="flex items-center gap-3"> | ||
| {policy.response === 'pass' ? ( | ||
| <div className="flex items-center gap-1 text-primary"> | ||
| <CheckCircle2 size={16} /> | ||
| <span>Pass</span> | ||
| </div> | ||
| ) : ( | ||
| <div className="flex items-center gap-1 text-red-600"> | ||
| <XCircle size={16} /> | ||
| <span>Fail</span> | ||
| </div> | ||
| )} | ||
| <DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}> | ||
| <DropdownMenuTrigger asChild> | ||
| <Button | ||
| variant="ghost" | ||
| size="sm" | ||
| className="h-8 w-8 p-0" | ||
| disabled={actions.length === 0} | ||
| > | ||
| <MoreVertical className="h-4 w-4" /> | ||
| </Button> | ||
| </DropdownMenuTrigger> | ||
| <DropdownMenuContent align="end"> | ||
| {actions.map(({ label, renderIcon, onClick }) => ( | ||
| <DropdownMenuItem | ||
| key={label} | ||
| onSelect={(event) => { | ||
| event.preventDefault(); | ||
| onClick(); | ||
| setDropdownOpen(false); | ||
| }} | ||
| > | ||
| {renderIcon()} | ||
| <span>{label}</span> | ||
| </DropdownMenuItem> | ||
| ))} | ||
| </DropdownMenuContent> | ||
| </DropdownMenu> | ||
| </div> | ||
| <PolicyImagePreviewModal | ||
| images={policy?.attachments || []} | ||
| open={isPreviewOpen} | ||
| onOpenChange={setIsPreviewOpen} | ||
| /> | ||
| </div> | ||
| ); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Carousel previous button blocks intended wrap-around navigation
Low Severity
The
CarouselControlscomponent disables the previous button whencurrentIndex === 0(viaisFirstVideo), butPolicyImagePreviewModalpasses agoPrevioushandler that implements wrap-around logic (prev === 0 ? images.length - 1 : prev - 1). This creates inconsistent carousel behavior where forward navigation wraps from last to first image, but backward navigation is blocked at the first image instead of wrapping to the last.Additional Locations (1)
apps/app/src/app/(app)/[orgId]/people/devices/components/PolicyImagePreviewModal.tsx#L30-L33