Skip to content

Commit 968c9bb

Browse files
committed
Add previews for text-based files
1 parent 4ac3b29 commit 968c9bb

4 files changed

Lines changed: 207 additions & 12 deletions

File tree

client/src/components/issue/AttachmentDialog.tsx

Lines changed: 114 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import {Download, X} from 'lucide-react';
1+
import {Download, X, FileText} from 'lucide-react';
2+
import {useState, useEffect} from 'react';
23
import {
34
Dialog,
45
DialogContent,
@@ -7,6 +8,7 @@ import {
78
} from '../ui/dialog';
89
import {Button} from '../ui/button';
910
import type {Attachment} from '@/types';
11+
import {isImageFile, isTextFile} from './fileUtils';
1012

1113
interface AttachmentDialogProps {
1214
isOpen: boolean;
@@ -17,6 +19,12 @@ interface AttachmentDialogProps {
1719
onDelete: (attachmentId: number) => void;
1820
}
1921

22+
interface TextPreview {
23+
content: string;
24+
filename: string;
25+
size: number;
26+
}
27+
2028
export const AttachmentDialog = ({
2129
isOpen,
2230
onClose,
@@ -25,6 +33,108 @@ export const AttachmentDialog = ({
2533
onDownload,
2634
onDelete
2735
}: AttachmentDialogProps) => {
36+
const [textPreview, setTextPreview] = useState<TextPreview | null>(null);
37+
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
38+
const [previewError, setPreviewError] = useState<string | null>(null);
39+
40+
useEffect(() => {
41+
if (attachment && isTextFile(attachment.original_filename) && isOpen) {
42+
loadTextPreview(attachment.id);
43+
} else {
44+
setTextPreview(null);
45+
setPreviewError(null);
46+
}
47+
}, [attachment, isOpen]);
48+
49+
const loadTextPreview = async (attachmentId: number) => {
50+
setIsLoadingPreview(true);
51+
setPreviewError(null);
52+
53+
try {
54+
const response = await fetch(`/api/attachments/${attachmentId}/preview`);
55+
if (!response.ok) {
56+
const error = await response.json();
57+
throw new Error(error.error || 'Failed to load preview');
58+
}
59+
60+
const preview = await response.json();
61+
setTextPreview(preview);
62+
} catch (error) {
63+
console.error('Failed to load text preview:', error);
64+
setPreviewError(error instanceof Error ? error.message : 'Failed to load preview');
65+
} finally {
66+
setIsLoadingPreview(false);
67+
}
68+
};
69+
70+
const renderContent = () => {
71+
if (!attachment) return null;
72+
73+
if (isImageFile(attachment.original_filename)) {
74+
return (
75+
<div className="flex items-center justify-center p-4">
76+
<img
77+
src={`/api/attachments/${attachment.id}`}
78+
alt={attachment.original_filename}
79+
className="max-w-full max-h-[70vh] object-contain rounded-lg"
80+
/>
81+
</div>
82+
);
83+
}
84+
85+
if (isTextFile(attachment.original_filename)) {
86+
if (isLoadingPreview) {
87+
return (
88+
<div className="flex items-center justify-center p-8">
89+
<div className="text-muted-foreground">Loading preview...</div>
90+
</div>
91+
);
92+
}
93+
94+
if (previewError) {
95+
return (
96+
<div className="p-4">
97+
<div className="flex items-center justify-center p-8 border border-red-500/20 rounded-lg bg-red-500/10">
98+
<div className="text-center">
99+
<FileText className="h-12 w-12 text-red-400 mx-auto mb-2" />
100+
<div className="text-red-400 font-medium">Preview not available</div>
101+
<div className="text-red-300 text-sm mt-1">{previewError}</div>
102+
</div>
103+
</div>
104+
</div>
105+
);
106+
}
107+
108+
if (textPreview) {
109+
return (
110+
<div className="p-4 w-full">
111+
<div className="bg-muted/30 rounded-lg border border-border overflow-hidden w-full">
112+
<div className="bg-muted/60 px-3 py-2 border-b border-border">
113+
<div className="text-xs text-muted-foreground">
114+
{textPreview.size} bytes
115+
</div>
116+
</div>
117+
<div className="max-h-[60vh] overflow-auto w-full">
118+
<pre className="text-sm p-4 whitespace-pre-wrap font-mono leading-relaxed break-all overflow-x-auto w-full min-w-0">
119+
{textPreview.content}
120+
</pre>
121+
</div>
122+
</div>
123+
</div>
124+
);
125+
}
126+
}
127+
128+
return (
129+
<div className="flex items-center justify-center p-8">
130+
<div className="text-center">
131+
<FileText className="h-12 w-12 text-muted-foreground mx-auto mb-2" />
132+
<div className="text-muted-foreground">Preview not available for this file type</div>
133+
</div>
134+
</div>
135+
);
136+
};
137+
28138
return (
29139
<Dialog open={isOpen} onOpenChange={onClose}>
30140
<DialogContent className="max-w-4xl max-h-[90vh] bg-zinc-900/95 border-zinc-800">
@@ -33,15 +143,9 @@ export const AttachmentDialog = ({
33143
{attachment?.original_filename}
34144
</DialogTitle>
35145
</DialogHeader>
36-
<div className="flex items-center justify-center p-4">
37-
{attachment && (
38-
<img
39-
src={`/api/attachments/${attachment.id}`}
40-
alt={attachment.original_filename}
41-
className="max-w-full max-h-[70vh] object-contain rounded-lg"
42-
/>
43-
)}
44-
</div>
146+
147+
{renderContent()}
148+
45149
<div className="flex justify-center space-x-2 pb-4">
46150
<Button
47151
variant="outline"

client/src/components/issue/AttachmentsList.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {Paperclip, Download, X} from 'lucide-react';
33
import {Card, CardContent, CardHeader, CardTitle} from '../ui/card';
44
import {Button} from '../ui/button';
55
import type {Attachment} from '@/types';
6-
import {isImageFile, getFileIcon} from './fileUtils';
6+
import {isImageFile, isTextFile, getFileIcon} from './fileUtils';
77

88
interface AttachmentsListProps {
99
attachments: Attachment[];
@@ -37,7 +37,7 @@ export const AttachmentsList = ({
3737
<div
3838
key={attachment.id}
3939
className={`group relative overflow-hidden rounded-lg border border-border bg-muted/40 transition-colors hover:border-accent ${
40-
isImageFile(attachment.original_filename) ? 'cursor-pointer hover:bg-muted/60' : ''
40+
isImageFile(attachment.original_filename) || isTextFile(attachment.original_filename) ? 'cursor-pointer hover:bg-muted/60' : ''
4141
}`}
4242
onClick={() => onAttachmentClick(attachment)}
4343
>
@@ -61,6 +61,14 @@ export const AttachmentsList = ({
6161
<div className="text-white text-sm font-medium">Click to view</div>
6262
</div>
6363
</div>
64+
) : isTextFile(attachment.original_filename) ? (
65+
<div className="aspect-video bg-muted flex items-center justify-center relative">
66+
<span className="text-4xl">{getFileIcon(attachment.original_filename)}</span>
67+
<div
68+
className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
69+
<div className="text-white text-sm font-medium">Click to preview</div>
70+
</div>
71+
</div>
6472
) : (
6573
<div className="aspect-video bg-muted flex items-center justify-center">
6674
<span className="text-4xl">{getFileIcon(attachment.original_filename)}</span>

client/src/components/issue/fileUtils.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,25 @@ export const isImageFile = (filename: string): boolean => {
44
return imageExtensions.includes(ext);
55
};
66

7+
export const isTextFile = (filename: string): boolean => {
8+
const textExtensions = [
9+
'.txt', '.md', '.log', '.json', '.xml', '.csv', '.yaml', '.yml',
10+
'.js', '.ts', '.jsx', '.tsx', '.html', '.css', '.scss', '.sass',
11+
'.py', '.java', '.c', '.cpp', '.h', '.hpp', '.php', '.rb', '.go',
12+
'.rs', '.sh', '.bat', '.ps1', '.sql', '.ini', '.conf', '.cfg',
13+
'.properties', '.toml', '.gitignore', '.env', '.dockerfile'
14+
];
15+
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
16+
return textExtensions.includes(ext);
17+
};
18+
719
export const getFileIcon = (filename: string): string => {
820
if (isImageFile(filename)) {
921
return '🖼️';
1022
}
23+
if (isTextFile(filename)) {
24+
return '📝';
25+
}
1126
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
1227
switch (ext) {
1328
case '.pdf':

server/src/routes/attachments.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,74 @@ router.get('/:id', optionalAuth, async (req, res) => {
108108
}
109109
});
110110

111+
router.get('/:id/preview', optionalAuth, async (req, res) => {
112+
try {
113+
const {id} = req.params;
114+
115+
const attachment = await Attachment.findByPk(id);
116+
if (!attachment) {
117+
return res.status(404).json({error: 'Attachment not found'});
118+
}
119+
120+
let projectId;
121+
let relatedEntity;
122+
if (attachment.related_type === 'post') {
123+
relatedEntity = await Post.findByPk(attachment.related_id);
124+
projectId = relatedEntity?.project_id;
125+
} else {
126+
relatedEntity = await Comment.findByPk(attachment.related_id, {
127+
include: [{model: Post, attributes: ['project_id']}],
128+
});
129+
projectId = relatedEntity?.Post?.project_id;
130+
}
131+
132+
if (!projectId) {
133+
return res.status(404).json({error: 'Related entity not found'});
134+
}
135+
136+
if (!fs.existsSync(attachment.file_path)) {
137+
return res.status(404).json({error: 'File not found on disk'});
138+
}
139+
140+
const textExtensions = [
141+
'.txt', '.md', '.log', '.json', '.xml', '.csv', '.yaml', '.yml',
142+
'.js', '.ts', '.jsx', '.tsx', '.html', '.css', '.scss', '.sass',
143+
'.py', '.java', '.c', '.cpp', '.h', '.hpp', '.php', '.rb', '.go',
144+
'.rs', '.sh', '.bat', '.ps1', '.sql', '.ini', '.conf', '.cfg',
145+
'.properties', '.toml', '.gitignore', '.env', '.dockerfile'
146+
];
147+
148+
const ext = attachment.original_filename.toLowerCase().substring(
149+
attachment.original_filename.lastIndexOf('.')
150+
);
151+
152+
if (!textExtensions.includes(ext)) {
153+
return res.status(400).json({error: 'File is not a text file'});
154+
}
155+
156+
const stats = fs.statSync(attachment.file_path);
157+
if (stats.size > 1024 * 1024) {
158+
return res.status(400).json({error: 'File too large for preview (max 1MB)'});
159+
}
160+
161+
const content = fs.readFileSync(attachment.file_path, 'utf8');
162+
163+
res.json({
164+
content,
165+
filename: attachment.original_filename,
166+
size: stats.size
167+
});
168+
} catch (error) {
169+
console.error('Preview error:', error);
170+
if (error.code === 'EISDIR') {
171+
return res.status(400).json({error: 'Cannot preview directory'});
172+
} else if (error.message.includes('Invalid character')) {
173+
return res.status(400).json({error: 'File contains non-text content'});
174+
}
175+
res.status(500).json({error: 'Internal server error'});
176+
}
177+
});
178+
111179
router.delete('/:id', authenticateToken, async (req, res) => {
112180
try {
113181
const {id} = req.params;

0 commit comments

Comments
 (0)