Skip to content

Commit f526c31

Browse files
committed
feat: add encrypted file attachments with drag-and-drop upload
- Add file upload functionality with client-side encryption - Support drag-and-drop and browse file selection (up to 10MB per file) - Files encrypted using same AES-256-GCM encryption as notes - Compact file display with download and delete actions - Real-time upload progress and deletion feedback - Refactor editor to controlled component pattern for better state management - Update README with file attachment documentation
1 parent e2240b2 commit f526c31

File tree

9 files changed

+712
-159
lines changed

9 files changed

+712
-159
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
- 🌙 **Dark/Light mode** - Easy on the eyes, day or night
3434
- 📝 **Rich text editor** - Powered by TipTap with markdown support
3535
-**Star, archive, and organize** - Keep your important notes accessible
36+
- 📎 **File attachments** - Upload and encrypt files up to 10MB per file
3637
- 🔍 **Full-text search** - Find notes instantly (searches encrypted data locally)
3738
- 📱 **Responsive design** - Works seamlessly on desktop and mobile
3839
- 🔄 **Real-time sync** - Access your notes from anywhere
@@ -43,9 +44,10 @@ Typelets uses industry-standard encryption:
4344
- **AES-256-GCM** encryption algorithm
4445
- **250,000 PBKDF2 iterations** for key derivation
4546
- **Per-note salt** - Each note has a unique encryption key
47+
- **File encryption** - Attachments are encrypted with the same security as notes
4648
- **Zero-knowledge architecture** - Server never sees unencrypted data
4749

48-
Your encryption keys are derived from your master password. Even if our database is compromised, your notes remain encrypted and unreadable.
50+
Your encryption keys are derived from your master password. Even if our database is compromised, your notes and files remain encrypted and unreadable.
4951

5052
### How It Works
5153

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import Index from '@/components/common/SEO';
1212
import { SEO_CONFIG } from '@/constants';
1313
import { api } from '@/lib/api/api.ts';
14+
import { fileService } from '@/services/fileService';
1415
import { clearUserEncryptionData } from '@/lib/encryption';
1516
import MainApp from '@/pages/MainApp';
1617

@@ -21,6 +22,7 @@ function AppContent() {
2122

2223
useEffect(() => {
2324
api.setTokenProvider(getToken);
25+
fileService.setTokenProvider(getToken);
2426
}, [getToken]);
2527

2628
useEffect(() => {
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { useState, useRef } from 'react';
2+
import { Upload, X, File, Download, Loader2 } from 'lucide-react';
3+
import { Button } from '@/components/ui/button';
4+
import { Progress } from '@/components/ui/progress';
5+
import type { FileAttachment } from '@/types/note';
6+
7+
interface FileUploadProps {
8+
attachments?: FileAttachment[];
9+
onUpload: (files: FileList) => Promise<void>;
10+
onRemove: (attachmentId: string) => Promise<void>;
11+
onDownload: (attachment: FileAttachment) => Promise<void>;
12+
isUploading?: boolean;
13+
uploadProgress?: number;
14+
deletingIds?: string[];
15+
}
16+
17+
export default function FileUpload({
18+
attachments = [],
19+
onUpload,
20+
onRemove,
21+
onDownload,
22+
isUploading = false,
23+
uploadProgress = 0,
24+
deletingIds = [],
25+
}: FileUploadProps) {
26+
const [isDragOver, setIsDragOver] = useState(false);
27+
const fileInputRef = useRef<HTMLInputElement>(null);
28+
29+
const handleDragOver = (e: React.DragEvent) => {
30+
e.preventDefault();
31+
setIsDragOver(true);
32+
};
33+
34+
const handleDragLeave = (e: React.DragEvent) => {
35+
e.preventDefault();
36+
setIsDragOver(false);
37+
};
38+
39+
const handleDrop = (e: React.DragEvent) => {
40+
e.preventDefault();
41+
setIsDragOver(false);
42+
43+
const files = e.dataTransfer.files;
44+
if (files.length > 0) {
45+
onUpload(files);
46+
}
47+
};
48+
49+
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
50+
const files = e.target.files;
51+
if (files && files.length > 0) {
52+
onUpload(files);
53+
}
54+
// Reset input value to allow selecting the same file again
55+
e.target.value = '';
56+
};
57+
58+
const formatFileSize = (bytes: number) => {
59+
if (bytes === 0) return '0 Bytes';
60+
const k = 1024;
61+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
62+
const i = Math.floor(Math.log(bytes) / Math.log(k));
63+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
64+
};
65+
66+
const getFileIcon = (mimeType: string) => {
67+
if (mimeType.startsWith('image/')) return '🖼️';
68+
if (mimeType.startsWith('video/')) return '🎥';
69+
if (mimeType.startsWith('audio/')) return '🎵';
70+
if (mimeType.includes('pdf')) return '📄';
71+
if (mimeType.includes('document') || mimeType.includes('word')) return '📝';
72+
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return '📊';
73+
return '📎';
74+
};
75+
76+
return (
77+
<div className="space-y-4">
78+
{/* Upload Area */}
79+
<div
80+
className={`border border-dashed rounded-lg p-3 text-center transition-colors ${
81+
isDragOver
82+
? 'border-primary bg-primary/5'
83+
: 'border-border hover:border-primary/50'
84+
}`}
85+
onDragOver={handleDragOver}
86+
onDragLeave={handleDragLeave}
87+
onDrop={handleDrop}
88+
>
89+
<input
90+
ref={fileInputRef}
91+
type="file"
92+
multiple
93+
className="hidden"
94+
onChange={handleFileSelect}
95+
/>
96+
97+
<div className="flex flex-col items-center gap-2">
98+
<Upload className="h-8 w-8 text-muted-foreground" />
99+
<p className="text-sm text-muted-foreground">
100+
Drag files here or{' '}
101+
<button
102+
type="button"
103+
className="text-primary hover:underline"
104+
onClick={() => fileInputRef.current?.click()}
105+
>
106+
browse
107+
</button>
108+
</p>
109+
</div>
110+
111+
{isUploading && (
112+
<div className="mt-4 space-y-2">
113+
<div className="flex items-center justify-center gap-2">
114+
<Loader2 className="h-4 w-4 animate-spin" />
115+
<span className="text-sm">Uploading...</span>
116+
</div>
117+
<Progress value={uploadProgress} className="w-full" />
118+
</div>
119+
)}
120+
</div>
121+
122+
{/* Attachments List */}
123+
{attachments.length > 0 && (
124+
<div className="space-y-3">
125+
<h4 className="text-sm font-medium">Attachments ({attachments.length})</h4>
126+
<div className="flex flex-wrap gap-2">
127+
{attachments.map((attachment) => {
128+
const isDeleting = deletingIds.includes(attachment.id);
129+
130+
return (
131+
<div
132+
key={attachment.id}
133+
className="flex items-center gap-2 p-2 border rounded-lg bg-card min-w-0 max-w-xs"
134+
>
135+
<div className="text-sm">
136+
{getFileIcon(attachment.mimeType)}
137+
</div>
138+
139+
<div className="flex-1 min-w-0">
140+
<p className="text-xs font-medium truncate">
141+
{attachment.originalName}
142+
</p>
143+
<p className="text-xs text-muted-foreground">
144+
{formatFileSize(attachment.size)}
145+
</p>
146+
</div>
147+
148+
<div className="flex items-center gap-1">
149+
<Button
150+
variant="ghost"
151+
size="sm"
152+
onClick={() => onDownload(attachment)}
153+
title="Download"
154+
className="h-6 w-6 p-0"
155+
disabled={isDeleting}
156+
>
157+
<Download className="h-3 w-3" />
158+
</Button>
159+
160+
<Button
161+
variant="ghost"
162+
size="sm"
163+
onClick={() => onRemove(attachment.id)}
164+
className="text-destructive hover:text-destructive h-6 w-6 p-0"
165+
title="Remove"
166+
disabled={isDeleting}
167+
>
168+
{isDeleting ? (
169+
<Loader2 className="h-3 w-3 animate-spin" />
170+
) : (
171+
<X className="h-3 w-3" />
172+
)}
173+
</Button>
174+
</div>
175+
</div>
176+
);
177+
})}
178+
</div>
179+
</div>
180+
)}
181+
</div>
182+
);
183+
}

0 commit comments

Comments
 (0)