Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"dependencies": {
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slot": "^1.1.2",
Expand Down
38 changes: 38 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

128 changes: 92 additions & 36 deletions src/client/components/generators/PreviewPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { ImageIcon } from 'lucide-react';
import { ImageIcon, Maximize2 } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { Skeleton } from '@/components/ui/skeleton';
import { CodeOutput } from './CodeOutput';

Expand All @@ -17,10 +18,14 @@ function ImagePreview({
url,
alt,
onStateChange,
onClick,
scaledDown = false,
}: {
url: string;
alt: string;
onStateChange: (state: ImageState) => void;
onClick?: () => void;
scaledDown?: boolean;
}) {
const [state, setState] = useState<ImageState>('loading');

Expand All @@ -34,19 +39,37 @@ function ImagePreview({
onStateChange('error');
};

const handleKeyDown = (e: React.KeyboardEvent) => {
if (onClick && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onClick();
}
};

return (
<img
src={url}
alt={alt}
onLoad={handleLoad}
onError={handleError}
className={`max-w-full transition-opacity duration-300 ${state === 'loaded' ? 'opacity-100' : 'hidden opacity-0'}`}
onClick={onClick}
onKeyDown={handleKeyDown}
tabIndex={scaledDown ? 0 : undefined}
role={scaledDown ? 'button' : undefined}
className={`transition-opacity duration-300 ${
state === 'loaded' ? 'opacity-100' : 'hidden opacity-0'
} ${
scaledDown
? 'w-full max-w-full cursor-pointer object-contain hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2'
: 'max-w-full'
}`}
/>
);
}

export const PreviewPanel = memo(function PreviewPanel({ url, alt }: PreviewPanelProps) {
const { t } = useTranslation();
const [dialogOpen, setDialogOpen] = useState(false);
// Track both the state and which URL it belongs to
const [stateForUrl, setStateForUrl] = useState<{ url: string | null; state: ImageState }>({
url: null,
Expand All @@ -67,42 +90,75 @@ export const PreviewPanel = memo(function PreviewPanel({ url, alt }: PreviewPane
const showImage = url && imageState === 'loaded';

return (
<Card className="border-border/50 bg-card/50 backdrop-blur-sm">
<CardHeader className="pb-4">
<CardTitle className="text-lg font-semibold tracking-tight">
{t('generator.preview')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex min-h-[220px] items-center justify-center rounded-xl bg-muted/30 p-6">
{showEmpty && (
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<ImageIcon className="h-10 w-10 opacity-50" />
<p className="text-center text-sm">{t('generator.previewEmpty')}</p>
</div>
)}
{showLoading && (
<div className="w-full max-w-md space-y-3">
<Skeleton className="mx-auto h-6 w-2/3" />
<Skeleton className="mx-auto h-28 w-full" />
<div className="flex justify-center gap-6">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-16" />
<>
<Card className="border-border/50 bg-card/50 backdrop-blur-sm">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-lg font-semibold tracking-tight">
{t('generator.preview')}
</CardTitle>
{showImage && (
<button
type="button"
onClick={() => setDialogOpen(true)}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={t('generator.fullscreen')}
>
<Maximize2 className="h-4 w-4" />
</button>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex min-h-[220px] items-center justify-center rounded-xl bg-muted/30 p-4 sm:p-6">
{showEmpty && (
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<ImageIcon className="h-10 w-10 opacity-50" />
<p className="text-center text-sm">{t('generator.previewEmpty')}</p>
</div>
</div>
)}
{showError && (
<p className="text-center text-sm text-destructive">{t('generator.previewError')}</p>
)}
)}
{showLoading && (
<div className="w-full max-w-md space-y-3">
<Skeleton className="mx-auto h-6 w-2/3" />
<Skeleton className="mx-auto h-28 w-full" />
<div className="flex justify-center gap-6">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-16" />
</div>
</div>
)}
{showError && (
<p className="text-center text-sm text-destructive">{t('generator.previewError')}</p>
)}
{url && (
// key={url} resets the ImagePreview component when URL changes
<ImagePreview
key={url}
url={url}
alt={alt}
onStateChange={handleStateChange}
onClick={() => setDialogOpen(true)}
scaledDown
/>
)}
</div>

{showImage && <CodeOutput url={url} alt={alt} />}
</CardContent>
</Card>

{/* Full-size preview dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-auto sm:max-w-fit">
<DialogTitle className="sr-only">{t('generator.preview')}</DialogTitle>
{url && (
// key={url} resets the ImagePreview component when URL changes
<ImagePreview key={url} url={url} alt={alt} onStateChange={handleStateChange} />
<div className="flex items-center justify-center p-2">
<img src={url} alt={alt} className="max-h-[80vh] max-w-full" />
</div>
)}
</div>

{showImage && <CodeOutput url={url} alt={alt} />}
</CardContent>
</Card>
</DialogContent>
</Dialog>
</>
);
});
115 changes: 115 additions & 0 deletions src/client/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils';

function Dialog({ ...props }: ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}

function DialogTrigger({ ...props }: ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}

function DialogPortal({ ...props }: ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}

function DialogClose({ ...props }: ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}

function DialogOverlay({ className, ...props }: ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
);
}

function DialogContent({
className,
children,
...props
}: ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
}

function DialogHeader({ className, ...props }: ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
{...props}
/>
);
}

function DialogFooter({ className, ...props }: ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
}

function DialogTitle({ className, ...props }: ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
);
}

function DialogDescription({
className,
...props
}: ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
}

export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
3 changes: 2 additions & 1 deletion src/client/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@
"preview": "Preview",
"previewEmpty": "Enter details to generate preview",
"previewLoading": "Loading...",
"previewError": "Failed to load. Check the username."
"previewError": "Failed to load. Check the username.",
"fullscreen": "View full size"
},
"stats": {
"title": "Stats Card Generator",
Expand Down
3 changes: 2 additions & 1 deletion src/client/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@
"preview": "プレビュー",
"previewEmpty": "詳細を入力してプレビュー",
"previewLoading": "読み込み中...",
"previewError": "読み込み失敗。ユーザー名を確認。"
"previewError": "読み込み失敗。ユーザー名を確認。",
"fullscreen": "フルサイズで表示"
},
"stats": {
"title": "統計カード作成",
Expand Down
Loading