+6
frontend/src/api/client.ts
+6
frontend/src/api/client.ts
···
58
58
return response.data;
59
59
};
60
60
61
+
export const editLink = async (id: number, data: Partial<CreateLinkRequest>) => {
62
+
const response = await api.patch<Link>(`/links/${id}`, data);
63
+
return response.data;
64
+
};
65
+
66
+
61
67
export const deleteLink = async (id: number) => {
62
68
await api.delete(`/links/${id}`);
63
69
};
+139
frontend/src/components/EditModal.tsx
+139
frontend/src/components/EditModal.tsx
···
1
+
// src/components/EditModal.tsx
2
+
import { useState } from 'react';
3
+
import { useForm } from 'react-hook-form';
4
+
import { zodResolver } from '@hookform/resolvers/zod';
5
+
import * as z from 'zod';
6
+
import { Link } from '../types/api';
7
+
import { editLink } from '../api/client';
8
+
import { useToast } from '@/hooks/use-toast';
9
+
import {
10
+
Dialog,
11
+
DialogContent,
12
+
DialogHeader,
13
+
DialogTitle,
14
+
DialogFooter,
15
+
} from '@/components/ui/dialog';
16
+
import { Button } from '@/components/ui/button';
17
+
import { Input } from '@/components/ui/input';
18
+
import {
19
+
Form,
20
+
FormControl,
21
+
FormField,
22
+
FormItem,
23
+
FormLabel,
24
+
FormMessage,
25
+
} from '@/components/ui/form';
26
+
27
+
const formSchema = z.object({
28
+
url: z
29
+
.string()
30
+
.min(1, 'URL is required')
31
+
.url('Must be a valid URL')
32
+
.refine((val) => val.startsWith('http://') || val.startsWith('https://'), {
33
+
message: 'URL must start with http:// or https://',
34
+
}),
35
+
custom_code: z
36
+
.string()
37
+
.regex(/^[a-zA-Z0-9_-]{1,32}$/, {
38
+
message:
39
+
'Custom code must be 1-32 characters and contain only letters, numbers, underscores, and hyphens',
40
+
})
41
+
.optional(),
42
+
});
43
+
44
+
interface EditModalProps {
45
+
isOpen: boolean;
46
+
onClose: () => void;
47
+
link: Link;
48
+
onSuccess: () => void;
49
+
}
50
+
51
+
export function EditModal({ isOpen, onClose, link, onSuccess }: EditModalProps) {
52
+
const [loading, setLoading] = useState(false);
53
+
const { toast } = useToast();
54
+
55
+
const form = useForm<z.infer<typeof formSchema>>({
56
+
resolver: zodResolver(formSchema),
57
+
defaultValues: {
58
+
url: link.original_url,
59
+
custom_code: link.short_code,
60
+
},
61
+
});
62
+
63
+
const onSubmit = async (values: z.infer<typeof formSchema>) => {
64
+
try {
65
+
setLoading(true);
66
+
await editLink(link.id, values);
67
+
toast({
68
+
description: 'Link updated successfully',
69
+
});
70
+
onSuccess();
71
+
onClose();
72
+
} catch (err: unknown) {
73
+
const error = err as { response?: { data?: { error?: string } } };
74
+
toast({
75
+
variant: 'destructive',
76
+
title: 'Error',
77
+
description: error.response?.data?.error || 'Failed to update link',
78
+
});
79
+
} finally {
80
+
setLoading(false);
81
+
}
82
+
};
83
+
84
+
return (
85
+
<Dialog open={isOpen} onOpenChange={onClose}>
86
+
<DialogContent>
87
+
<DialogHeader>
88
+
<DialogTitle>Edit Link</DialogTitle>
89
+
</DialogHeader>
90
+
91
+
<Form {...form}>
92
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
93
+
<FormField
94
+
control={form.control}
95
+
name="url"
96
+
render={({ field }) => (
97
+
<FormItem>
98
+
<FormLabel>Destination URL</FormLabel>
99
+
<FormControl>
100
+
<Input placeholder="https://example.com" {...field} />
101
+
</FormControl>
102
+
<FormMessage />
103
+
</FormItem>
104
+
)}
105
+
/>
106
+
107
+
<FormField
108
+
control={form.control}
109
+
name="custom_code"
110
+
render={({ field }) => (
111
+
<FormItem>
112
+
<FormLabel>Short Code</FormLabel>
113
+
<FormControl>
114
+
<Input placeholder="custom-code" {...field} />
115
+
</FormControl>
116
+
<FormMessage />
117
+
</FormItem>
118
+
)}
119
+
/>
120
+
121
+
<DialogFooter>
122
+
<Button
123
+
type="button"
124
+
variant="outline"
125
+
onClick={onClose}
126
+
disabled={loading}
127
+
>
128
+
Cancel
129
+
</Button>
130
+
<Button type="submit" disabled={loading}>
131
+
{loading ? 'Saving...' : 'Save Changes'}
132
+
</Button>
133
+
</DialogFooter>
134
+
</form>
135
+
</Form>
136
+
</DialogContent>
137
+
</Dialog>
138
+
);
139
+
}
+44
-19
frontend/src/components/LinkList.tsx
+44
-19
frontend/src/components/LinkList.tsx
···
1
-
import { useEffect, useState } from 'react'
1
+
import { useCallback, useEffect, useState } from 'react'
2
2
import { Link } from '../types/api'
3
3
import { getAllLinks, deleteLink } from '../api/client'
4
4
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
···
12
12
} from "@/components/ui/table"
13
13
import { Button } from "@/components/ui/button"
14
14
import { useToast } from "@/hooks/use-toast"
15
-
import { Copy, Trash2, BarChart2 } from "lucide-react"
15
+
import { Copy, Trash2, BarChart2, Pencil } from "lucide-react"
16
16
import {
17
17
Dialog,
18
18
DialogContent,
···
23
23
} from "@/components/ui/dialog"
24
24
25
25
import { StatisticsModal } from "./StatisticsModal"
26
+
import { EditModal } from './EditModal'
26
27
27
28
interface LinkListProps {
28
29
refresh?: number;
···
39
40
isOpen: false,
40
41
linkId: null,
41
42
});
43
+
const [editModal, setEditModal] = useState<{ isOpen: boolean; link: Link | null }>({
44
+
isOpen: false,
45
+
link: null,
46
+
});
42
47
const { toast } = useToast()
43
48
44
-
const fetchLinks = async () => {
49
+
const fetchLinks = useCallback(async () => {
45
50
try {
46
51
setLoading(true)
47
52
const data = await getAllLinks()
48
53
setLinks(data)
49
-
} catch (err) {
54
+
} catch (err: unknown) {
55
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
50
56
toast({
51
57
title: "Error",
52
-
description: "Failed to load links",
58
+
description: `Failed to load links: ${errorMessage}`,
53
59
variant: "destructive",
54
60
})
55
61
} finally {
56
62
setLoading(false)
57
63
}
58
-
}
64
+
}, [toast, setLinks, setLoading])
59
65
60
66
useEffect(() => {
61
67
fetchLinks()
62
-
}, [refresh]) // Re-fetch when refresh counter changes
68
+
}, [fetchLinks, refresh]) // Re-fetch when refresh counter changes
63
69
64
70
const handleDelete = async () => {
65
71
if (!deleteModal.linkId) return
···
71
77
toast({
72
78
description: "Link deleted successfully",
73
79
})
74
-
} catch (err) {
80
+
} catch (err: unknown) {
81
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
75
82
toast({
76
83
title: "Error",
77
-
description: "Failed to delete link",
84
+
description: `Failed to delete link: ${errorMessage}`,
78
85
variant: "destructive",
79
86
})
80
87
}
···
85
92
const baseUrl = window.location.origin
86
93
navigator.clipboard.writeText(`${baseUrl}/${shortCode}`)
87
94
toast({
88
-
description: (
89
-
<>
90
-
Link copied to clipboard
91
-
<br />
92
-
You can add ?source=TextHere to the end of the link to track the source of clicks
93
-
</>
94
-
),
95
+
description: (
96
+
<>
97
+
Link copied to clipboard
98
+
<br />
99
+
You can add ?source=TextHere to the end of the link to track the source of clicks
100
+
</>
101
+
),
95
102
})
96
103
}
97
104
···
127
134
</CardHeader>
128
135
<CardContent>
129
136
<div className="rounded-md border">
137
+
130
138
<Table>
131
139
<TableHeader>
132
140
<TableRow>
···
134
142
<TableHead className="hidden md:table-cell">Original URL</TableHead>
135
143
<TableHead>Clicks</TableHead>
136
144
<TableHead className="hidden md:table-cell">Created</TableHead>
137
-
<TableHead>Actions</TableHead>
145
+
<TableHead className="w-[1%] whitespace-nowrap pr-4">Actions</TableHead>
138
146
</TableRow>
139
147
</TableHeader>
140
148
<TableBody>
···
148
156
<TableCell className="hidden md:table-cell">
149
157
{new Date(link.created_at).toLocaleDateString()}
150
158
</TableCell>
151
-
<TableCell>
152
-
<div className="flex gap-2">
159
+
<TableCell className="p-2 pr-4">
160
+
<div className="flex items-center gap-1">
153
161
<Button
154
162
variant="ghost"
155
163
size="icon"
···
171
179
<Button
172
180
variant="ghost"
173
181
size="icon"
182
+
className="h-8 w-8"
183
+
onClick={() => setEditModal({ isOpen: true, link })}
184
+
>
185
+
<Pencil className="h-4 w-4" />
186
+
<span className="sr-only">Edit Link</span>
187
+
</Button>
188
+
<Button
189
+
variant="ghost"
190
+
size="icon"
174
191
className="h-8 w-8 text-destructive"
175
192
onClick={() => setDeleteModal({ isOpen: true, linkId: link.id })}
176
193
>
···
191
208
onClose={() => setStatsModal({ isOpen: false, linkId: null })}
192
209
linkId={statsModal.linkId!}
193
210
/>
211
+
{editModal.link && (
212
+
<EditModal
213
+
isOpen={editModal.isOpen}
214
+
onClose={() => setEditModal({ isOpen: false, link: null })}
215
+
link={editModal.link}
216
+
onSuccess={fetchLinks}
217
+
/>
218
+
)}
194
219
</>
195
220
)
196
221
}
+3
-3
frontend/src/components/StatisticsModal.tsx
+3
-3
frontend/src/components/StatisticsModal.tsx
···
31
31
label,
32
32
}: {
33
33
active?: boolean;
34
-
payload?: any[];
34
+
payload?: { value: number; payload: EnhancedClickStats }[];
35
35
label?: string;
36
36
}) => {
37
37
if (active && payload && payload.length > 0) {
···
81
81
82
82
setClicksOverTime(enhancedClicksData);
83
83
setSourcesData(sourcesData);
84
-
} catch (error: any) {
84
+
} catch (error: unknown) {
85
85
console.error("Failed to fetch statistics:", error);
86
86
toast({
87
87
variant: "destructive",
88
88
title: "Error",
89
-
description: error.response?.data || "Failed to load statistics",
89
+
description: error instanceof Error ? error.message : "Failed to load statistics",
90
90
});
91
91
} finally {
92
92
setLoading(false);
+28
-15
frontend/vite.config.ts
+28
-15
frontend/vite.config.ts
···
3
3
import tailwindcss from '@tailwindcss/vite'
4
4
import path from "path"
5
5
6
-
export default defineConfig(() => ({
7
-
plugins: [react(), tailwindcss()],
8
-
/*server: {
9
-
proxy: {
10
-
'/api': {
11
-
target: process.env.VITE_API_URL || 'http://localhost:8080',
12
-
changeOrigin: true,
6
+
export default defineConfig(({ command }) => {
7
+
if (command === 'serve') { //command == 'dev'
8
+
return {
9
+
server: {
10
+
proxy: {
11
+
'/api': {
12
+
target: process.env.VITE_API_URL || 'http://localhost:8080',
13
+
changeOrigin: true,
14
+
},
15
+
},
16
+
},
17
+
plugins: [react(), tailwindcss()],
18
+
resolve: {
19
+
alias: {
20
+
"@": path.resolve(__dirname, "./src"),
21
+
},
22
+
},
23
+
}
24
+
} else { //command === 'build'
25
+
return {
26
+
plugins: [react(), tailwindcss()],
27
+
resolve: {
28
+
alias: {
29
+
"@": path.resolve(__dirname, "./src"),
30
+
},
13
31
},
14
-
},
15
-
},*/
16
-
resolve: {
17
-
alias: {
18
-
"@": path.resolve(__dirname, "./src"),
19
-
},
20
-
},
21
-
}))
32
+
}
33
+
}
34
+
})
+138
-1
src/handlers.rs
+138
-1
src/handlers.rs
···
457
457
}))
458
458
}
459
459
460
+
pub async fn edit_link(
461
+
state: web::Data<AppState>,
462
+
user: AuthenticatedUser,
463
+
path: web::Path<i32>,
464
+
payload: web::Json<CreateLink>,
465
+
) -> Result<impl Responder, AppError> {
466
+
let link_id: i32 = path.into_inner();
467
+
468
+
// Validate the new URL if provided
469
+
validate_url(&payload.url)?;
470
+
471
+
// Validate custom code if provided
472
+
if let Some(ref custom_code) = payload.custom_code {
473
+
validate_custom_code(custom_code)?;
474
+
475
+
// Check if the custom code is already taken by another link
476
+
let existing_link = match &state.db {
477
+
DatabasePool::Postgres(pool) => {
478
+
sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = $1 AND id != $2")
479
+
.bind(custom_code)
480
+
.bind(link_id)
481
+
.fetch_optional(pool)
482
+
.await?
483
+
}
484
+
DatabasePool::Sqlite(pool) => {
485
+
sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = ?1 AND id != ?2")
486
+
.bind(custom_code)
487
+
.bind(link_id)
488
+
.fetch_optional(pool)
489
+
.await?
490
+
}
491
+
};
492
+
493
+
if existing_link.is_some() {
494
+
return Err(AppError::InvalidInput(
495
+
"Custom code already taken".to_string(),
496
+
));
497
+
}
498
+
}
499
+
500
+
// Update the link
501
+
let updated_link = match &state.db {
502
+
DatabasePool::Postgres(pool) => {
503
+
let mut tx = pool.begin().await?;
504
+
505
+
// First verify the link belongs to the user
506
+
let link =
507
+
sqlx::query_as::<_, Link>("SELECT * FROM links WHERE id = $1 AND user_id = $2")
508
+
.bind(link_id)
509
+
.bind(user.user_id)
510
+
.fetch_optional(&mut *tx)
511
+
.await?;
512
+
513
+
if link.is_none() {
514
+
return Err(AppError::NotFound);
515
+
}
516
+
517
+
// Update the link
518
+
let updated = sqlx::query_as::<_, Link>(
519
+
r#"
520
+
UPDATE links
521
+
SET
522
+
original_url = $1,
523
+
short_code = COALESCE($2, short_code)
524
+
WHERE id = $3 AND user_id = $4
525
+
RETURNING *
526
+
"#,
527
+
)
528
+
.bind(&payload.url)
529
+
.bind(&payload.custom_code)
530
+
.bind(link_id)
531
+
.bind(user.user_id)
532
+
.fetch_one(&mut *tx)
533
+
.await?;
534
+
535
+
// If source is provided, add a click record
536
+
if let Some(ref source) = payload.source {
537
+
sqlx::query("INSERT INTO clicks (link_id, source) VALUES ($1, $2)")
538
+
.bind(link_id)
539
+
.bind(source)
540
+
.execute(&mut *tx)
541
+
.await?;
542
+
}
543
+
544
+
tx.commit().await?;
545
+
updated
546
+
}
547
+
DatabasePool::Sqlite(pool) => {
548
+
let mut tx = pool.begin().await?;
549
+
550
+
// First verify the link belongs to the user
551
+
let link =
552
+
sqlx::query_as::<_, Link>("SELECT * FROM links WHERE id = ?1 AND user_id = ?2")
553
+
.bind(link_id)
554
+
.bind(user.user_id)
555
+
.fetch_optional(&mut *tx)
556
+
.await?;
557
+
558
+
if link.is_none() {
559
+
return Err(AppError::NotFound);
560
+
}
561
+
562
+
// Update the link
563
+
let updated = sqlx::query_as::<_, Link>(
564
+
r#"
565
+
UPDATE links
566
+
SET
567
+
original_url = ?1,
568
+
short_code = COALESCE(?2, short_code)
569
+
WHERE id = ?3 AND user_id = ?4
570
+
RETURNING *
571
+
"#,
572
+
)
573
+
.bind(&payload.url)
574
+
.bind(&payload.custom_code)
575
+
.bind(link_id)
576
+
.bind(user.user_id)
577
+
.fetch_one(&mut *tx)
578
+
.await?;
579
+
580
+
// If source is provided, add a click record
581
+
if let Some(ref source) = payload.source {
582
+
sqlx::query("INSERT INTO clicks (link_id, source) VALUES (?1, ?2)")
583
+
.bind(link_id)
584
+
.bind(source)
585
+
.execute(&mut *tx)
586
+
.await?;
587
+
}
588
+
589
+
tx.commit().await?;
590
+
updated
591
+
}
592
+
};
593
+
594
+
Ok(HttpResponse::Ok().json(updated_link))
595
+
}
596
+
460
597
pub async fn delete_link(
461
598
state: web::Data<AppState>,
462
599
user: AuthenticatedUser,
463
600
path: web::Path<i32>,
464
601
) -> Result<impl Responder, AppError> {
465
-
let link_id = path.into_inner();
602
+
let link_id: i32 = path.into_inner();
466
603
467
604
match &state.db {
468
605
DatabasePool::Postgres(pool) => {
+1
src/main.rs
+1
src/main.rs