Skip to content

Commit d76b57a

Browse files
authored
Merge pull request #596 from objectstack-ai/copilot/develop-roadmap-console-features
2 parents a05168a + 562883b commit d76b57a

26 files changed

Lines changed: 4740 additions & 64 deletions

ROADMAP_CONSOLE.md

Lines changed: 74 additions & 58 deletions
Large diffs are not rendered by default.
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import * as React from 'react';
10+
import { cn, Button } from '@object-ui/components';
11+
import { Paperclip, X, FileText, Image, FileArchive, File, Upload } from 'lucide-react';
12+
13+
export interface Attachment {
14+
id: string;
15+
name: string;
16+
size: number;
17+
type: string;
18+
url?: string;
19+
thumbnailUrl?: string;
20+
}
21+
22+
export interface CommentAttachmentProps {
23+
attachments: Attachment[];
24+
onUpload?: (files: FileList) => void | Promise<void>;
25+
onRemove?: (attachmentId: string) => void;
26+
className?: string;
27+
readOnly?: boolean;
28+
}
29+
30+
function formatFileSize(bytes: number): string {
31+
if (bytes < 1024) return `${bytes} B`;
32+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
33+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
34+
}
35+
36+
function isImageType(type: string): boolean {
37+
return type.startsWith('image/');
38+
}
39+
40+
function getFileIcon(type: string): React.ElementType {
41+
if (isImageType(type)) return Image;
42+
if (type.includes('pdf') || type.includes('document') || type.includes('text'))
43+
return FileText;
44+
if (type.includes('zip') || type.includes('archive') || type.includes('compressed'))
45+
return FileArchive;
46+
return File;
47+
}
48+
49+
export const CommentAttachment: React.FC<CommentAttachmentProps> = ({
50+
attachments,
51+
onUpload,
52+
onRemove,
53+
className,
54+
readOnly = false,
55+
}) => {
56+
const [isDragOver, setIsDragOver] = React.useState(false);
57+
const fileInputRef = React.useRef<HTMLInputElement>(null);
58+
59+
const handleDragOver = React.useCallback((e: React.DragEvent) => {
60+
e.preventDefault();
61+
e.stopPropagation();
62+
setIsDragOver(true);
63+
}, []);
64+
65+
const handleDragLeave = React.useCallback((e: React.DragEvent) => {
66+
e.preventDefault();
67+
e.stopPropagation();
68+
setIsDragOver(false);
69+
}, []);
70+
71+
const handleDrop = React.useCallback(
72+
(e: React.DragEvent) => {
73+
e.preventDefault();
74+
e.stopPropagation();
75+
setIsDragOver(false);
76+
if (onUpload && e.dataTransfer.files.length > 0) {
77+
onUpload(e.dataTransfer.files);
78+
}
79+
},
80+
[onUpload],
81+
);
82+
83+
const handleFileSelect = React.useCallback(
84+
(e: React.ChangeEvent<HTMLInputElement>) => {
85+
if (onUpload && e.target.files && e.target.files.length > 0) {
86+
onUpload(e.target.files);
87+
// Reset so the same file can be selected again
88+
e.target.value = '';
89+
}
90+
},
91+
[onUpload],
92+
);
93+
94+
return (
95+
<div className={cn('space-y-2', className)}>
96+
{/* Drop zone */}
97+
{onUpload && !readOnly && (
98+
<div
99+
className={cn(
100+
'border-2 border-dashed rounded-md px-4 py-3 text-center transition-colors cursor-pointer',
101+
isDragOver
102+
? 'border-primary bg-primary/5'
103+
: 'border-muted-foreground/25 hover:border-muted-foreground/40',
104+
)}
105+
onDragOver={handleDragOver}
106+
onDragLeave={handleDragLeave}
107+
onDrop={handleDrop}
108+
onClick={() => fileInputRef.current?.click()}
109+
role="button"
110+
tabIndex={0}
111+
onKeyDown={(e) => {
112+
if (e.key === 'Enter' || e.key === ' ') {
113+
e.preventDefault();
114+
fileInputRef.current?.click();
115+
}
116+
}}
117+
>
118+
<Upload className="h-5 w-5 mx-auto text-muted-foreground mb-1" />
119+
<p className="text-xs text-muted-foreground">
120+
Drop files here or click to upload
121+
</p>
122+
<input
123+
ref={fileInputRef}
124+
type="file"
125+
multiple
126+
className="hidden"
127+
onChange={handleFileSelect}
128+
/>
129+
</div>
130+
)}
131+
132+
{/* Attachment list */}
133+
{attachments.length > 0 && (
134+
<div className="space-y-1.5">
135+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
136+
<Paperclip className="h-3 w-3" />
137+
<span>
138+
{attachments.length} attachment{attachments.length !== 1 ? 's' : ''}
139+
</span>
140+
</div>
141+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
142+
{attachments.map((attachment) => {
143+
const isImage = isImageType(attachment.type);
144+
const Icon = getFileIcon(attachment.type);
145+
146+
return (
147+
<div
148+
key={attachment.id}
149+
className="flex items-center gap-2 rounded-md border px-2.5 py-2 bg-muted/30 group"
150+
>
151+
{/* Thumbnail or icon */}
152+
{isImage && (attachment.thumbnailUrl || attachment.url) ? (
153+
<img
154+
src={attachment.thumbnailUrl || attachment.url}
155+
alt={attachment.name}
156+
className="h-10 w-10 rounded object-cover shrink-0"
157+
/>
158+
) : (
159+
<div className="h-10 w-10 rounded bg-muted flex items-center justify-center shrink-0">
160+
<Icon className="h-5 w-5 text-muted-foreground" />
161+
</div>
162+
)}
163+
164+
{/* File info */}
165+
<div className="flex-1 min-w-0">
166+
<p className="text-xs font-medium truncate">{attachment.name}</p>
167+
<p className="text-[10px] text-muted-foreground">
168+
{formatFileSize(attachment.size)}
169+
</p>
170+
</div>
171+
172+
{/* Remove button */}
173+
{onRemove && !readOnly && (
174+
<Button
175+
variant="ghost"
176+
size="icon"
177+
className="h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
178+
onClick={() => onRemove(attachment.id)}
179+
title="Remove attachment"
180+
>
181+
<X className="h-3.5 w-3.5" />
182+
</Button>
183+
)}
184+
</div>
185+
);
186+
})}
187+
</div>
188+
</div>
189+
)}
190+
</div>
191+
);
192+
};

0 commit comments

Comments
 (0)