Skip to content

Commit d5f04de

Browse files
committed
feat: add FinalOutputView component and related types for file handling in instructions wizard
1 parent 0f2a088 commit d5f04de

File tree

3 files changed

+412
-97
lines changed

3 files changed

+412
-97
lines changed

components/final-output-view.tsx

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"use client"
2+
3+
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
4+
import type { MouseEvent } from "react"
5+
import { Copy, Download, X } from "lucide-react"
6+
7+
import { Button } from "@/components/ui/button"
8+
import type { FinalOutputViewProps } from "@/types/output"
9+
10+
const COPY_RESET_DELAY = 2200
11+
12+
export default function FinalOutputView({ fileName, fileContent, mimeType, onClose }: FinalOutputViewProps) {
13+
const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "error">("idle")
14+
const resetTimerRef = useRef<number | null>(null)
15+
const dialogRef = useRef<HTMLDivElement | null>(null)
16+
17+
const normalizedFileName = useMemo(() => {
18+
const trimmed = fileName?.trim()
19+
return trimmed && trimmed.length > 0 ? trimmed : "instructions.txt"
20+
}, [fileName])
21+
22+
useEffect(() => {
23+
setCopyStatus("idle")
24+
if (resetTimerRef.current !== null) {
25+
window.clearTimeout(resetTimerRef.current)
26+
resetTimerRef.current = null
27+
}
28+
}, [fileContent, normalizedFileName])
29+
30+
useEffect(() => {
31+
const previousOverflow = document.body.style.overflow
32+
document.body.style.overflow = "hidden"
33+
34+
const handleKeyDown = (event: KeyboardEvent) => {
35+
if (event.key === "Escape") {
36+
onClose?.()
37+
}
38+
}
39+
40+
window.addEventListener("keydown", handleKeyDown)
41+
dialogRef.current?.focus()
42+
43+
return () => {
44+
if (resetTimerRef.current !== null) {
45+
window.clearTimeout(resetTimerRef.current)
46+
}
47+
document.body.style.overflow = previousOverflow
48+
window.removeEventListener("keydown", handleKeyDown)
49+
}
50+
}, [onClose])
51+
52+
const handleCopyClick = useCallback(async () => {
53+
if (!fileContent) {
54+
return
55+
}
56+
57+
try {
58+
if (typeof navigator === "undefined" || !navigator.clipboard) {
59+
throw new Error("Clipboard API not available")
60+
}
61+
62+
await navigator.clipboard.writeText(fileContent)
63+
setCopyStatus("copied")
64+
} catch {
65+
setCopyStatus("error")
66+
}
67+
68+
if (resetTimerRef.current !== null) {
69+
window.clearTimeout(resetTimerRef.current)
70+
}
71+
72+
resetTimerRef.current = window.setTimeout(() => {
73+
setCopyStatus("idle")
74+
resetTimerRef.current = null
75+
}, COPY_RESET_DELAY)
76+
}, [fileContent])
77+
78+
const handleDownloadClick = useCallback(() => {
79+
if (!fileContent) {
80+
return
81+
}
82+
83+
const downloadMimeType = mimeType ?? "text/plain;charset=utf-8"
84+
const blob = new Blob([fileContent], { type: downloadMimeType })
85+
const url = URL.createObjectURL(blob)
86+
87+
const link = document.createElement("a")
88+
link.href = url
89+
link.download = normalizedFileName
90+
link.style.display = "none"
91+
document.body.appendChild(link)
92+
link.click()
93+
document.body.removeChild(link)
94+
95+
URL.revokeObjectURL(url)
96+
}, [fileContent, mimeType, normalizedFileName])
97+
98+
const handleBackdropClick = () => {
99+
onClose?.()
100+
}
101+
102+
const handleDialogClick = (event: MouseEvent<HTMLDivElement>) => {
103+
event.stopPropagation()
104+
}
105+
106+
const displayedContent = fileContent && fileContent.length > 0 ? fileContent : "No content available."
107+
108+
return (
109+
<div
110+
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-4 backdrop-blur-sm overscroll-contain"
111+
role="dialog"
112+
aria-modal="true"
113+
aria-labelledby="final-output-title"
114+
aria-describedby="final-output-description"
115+
onClick={handleBackdropClick}
116+
>
117+
<div
118+
ref={dialogRef}
119+
className="relative flex h-full max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-3xl border border-border/70 bg-card shadow-2xl"
120+
tabIndex={-1}
121+
onClick={handleDialogClick}
122+
>
123+
<header className="flex flex-col gap-4 border-b border-border/60 px-6 py-5 md:flex-row md:items-center md:justify-between">
124+
<div>
125+
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground" id="final-output-description">
126+
Instructions ready
127+
</p>
128+
<h2 className="text-lg font-semibold text-foreground" id="final-output-title">
129+
{normalizedFileName}
130+
</h2>
131+
</div>
132+
<div className="flex flex-wrap items-center gap-2">
133+
<Button variant="outline" size="sm" onClick={handleCopyClick} disabled={!fileContent}>
134+
<Copy className="mr-2 h-4 w-4" aria-hidden />
135+
{copyStatus === "copied"
136+
? "Copied"
137+
: copyStatus === "error"
138+
? "Copy Failed"
139+
: "Copy"}
140+
</Button>
141+
<Button size="sm" onClick={handleDownloadClick} disabled={!fileContent}>
142+
<Download className="mr-2 h-4 w-4" aria-hidden />
143+
Download
144+
</Button>
145+
<Button
146+
variant="ghost"
147+
size="icon"
148+
onClick={onClose}
149+
className="ml-2"
150+
>
151+
<X className="h-4 w-4" aria-hidden />
152+
<span className="sr-only">Close preview</span>
153+
</Button>
154+
</div>
155+
</header>
156+
<div className="relative flex-1 min-h-0 bg-muted/20 p-6">
157+
<div className="flex h-full flex-1 rounded-2xl border border-border/60 bg-background/95 shadow-inner">
158+
<pre className="min-h-0 h-full w-full overflow-auto whitespace-pre-wrap break-words rounded-2xl bg-transparent p-6 font-mono text-sm leading-relaxed text-foreground">
159+
<code>{displayedContent}</code>
160+
</pre>
161+
</div>
162+
</div>
163+
<span className="sr-only" aria-live="polite">
164+
{copyStatus === "copied"
165+
? "File content copied to clipboard"
166+
: copyStatus === "error"
167+
? "Unable to copy file content"
168+
: ""}
169+
</span>
170+
</div>
171+
</div>
172+
)
173+
}

0 commit comments

Comments
 (0)