Skip to content

Commit 8c4e307

Browse files
authored
Merge pull request #13 from WaveringAna/editLink
Add edit link functionality
2 parents 2f7147d + b514d49 commit 8c4e307

File tree

7 files changed

+359
-38
lines changed

7 files changed

+359
-38
lines changed

frontend/src/api/client.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ export const getAllLinks = async () => {
5858
return response.data;
5959
};
6060

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+
6167
export const deleteLink = async (id: number) => {
6268
await api.delete(`/links/${id}`);
6369
};
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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+
}

frontend/src/components/LinkList.tsx

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from 'react'
1+
import { useCallback, useEffect, useState } from 'react'
22
import { Link } from '../types/api'
33
import { getAllLinks, deleteLink } from '../api/client'
44
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -12,7 +12,7 @@ import {
1212
} from "@/components/ui/table"
1313
import { Button } from "@/components/ui/button"
1414
import { useToast } from "@/hooks/use-toast"
15-
import { Copy, Trash2, BarChart2 } from "lucide-react"
15+
import { Copy, Trash2, BarChart2, Pencil } from "lucide-react"
1616
import {
1717
Dialog,
1818
DialogContent,
@@ -23,6 +23,7 @@ import {
2323
} from "@/components/ui/dialog"
2424

2525
import { StatisticsModal } from "./StatisticsModal"
26+
import { EditModal } from './EditModal'
2627

2728
interface LinkListProps {
2829
refresh?: number;
@@ -39,27 +40,32 @@ export function LinkList({ refresh = 0 }: LinkListProps) {
3940
isOpen: false,
4041
linkId: null,
4142
});
43+
const [editModal, setEditModal] = useState<{ isOpen: boolean; link: Link | null }>({
44+
isOpen: false,
45+
link: null,
46+
});
4247
const { toast } = useToast()
4348

44-
const fetchLinks = async () => {
49+
const fetchLinks = useCallback(async () => {
4550
try {
4651
setLoading(true)
4752
const data = await getAllLinks()
4853
setLinks(data)
49-
} catch (err) {
54+
} catch (err: unknown) {
55+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
5056
toast({
5157
title: "Error",
52-
description: "Failed to load links",
58+
description: `Failed to load links: ${errorMessage}`,
5359
variant: "destructive",
5460
})
5561
} finally {
5662
setLoading(false)
5763
}
58-
}
64+
}, [toast, setLinks, setLoading])
5965

6066
useEffect(() => {
6167
fetchLinks()
62-
}, [refresh]) // Re-fetch when refresh counter changes
68+
}, [fetchLinks, refresh]) // Re-fetch when refresh counter changes
6369

6470
const handleDelete = async () => {
6571
if (!deleteModal.linkId) return
@@ -71,10 +77,11 @@ export function LinkList({ refresh = 0 }: LinkListProps) {
7177
toast({
7278
description: "Link deleted successfully",
7379
})
74-
} catch (err) {
80+
} catch (err: unknown) {
81+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
7582
toast({
7683
title: "Error",
77-
description: "Failed to delete link",
84+
description: `Failed to delete link: ${errorMessage}`,
7885
variant: "destructive",
7986
})
8087
}
@@ -85,13 +92,13 @@ export function LinkList({ refresh = 0 }: LinkListProps) {
8592
const baseUrl = window.location.origin
8693
navigator.clipboard.writeText(`${baseUrl}/${shortCode}`)
8794
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+
),
95102
})
96103
}
97104

@@ -127,14 +134,15 @@ export function LinkList({ refresh = 0 }: LinkListProps) {
127134
</CardHeader>
128135
<CardContent>
129136
<div className="rounded-md border">
137+
130138
<Table>
131139
<TableHeader>
132140
<TableRow>
133141
<TableHead>Short Code</TableHead>
134142
<TableHead className="hidden md:table-cell">Original URL</TableHead>
135143
<TableHead>Clicks</TableHead>
136144
<TableHead className="hidden md:table-cell">Created</TableHead>
137-
<TableHead>Actions</TableHead>
145+
<TableHead className="w-[1%] whitespace-nowrap pr-4">Actions</TableHead>
138146
</TableRow>
139147
</TableHeader>
140148
<TableBody>
@@ -148,8 +156,8 @@ export function LinkList({ refresh = 0 }: LinkListProps) {
148156
<TableCell className="hidden md:table-cell">
149157
{new Date(link.created_at).toLocaleDateString()}
150158
</TableCell>
151-
<TableCell>
152-
<div className="flex gap-2">
159+
<TableCell className="p-2 pr-4">
160+
<div className="flex items-center gap-1">
153161
<Button
154162
variant="ghost"
155163
size="icon"
@@ -168,6 +176,15 @@ export function LinkList({ refresh = 0 }: LinkListProps) {
168176
<BarChart2 className="h-4 w-4" />
169177
<span className="sr-only">View statistics</span>
170178
</Button>
179+
<Button
180+
variant="ghost"
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>
171188
<Button
172189
variant="ghost"
173190
size="icon"
@@ -191,6 +208,14 @@ export function LinkList({ refresh = 0 }: LinkListProps) {
191208
onClose={() => setStatsModal({ isOpen: false, linkId: null })}
192209
linkId={statsModal.linkId!}
193210
/>
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+
)}
194219
</>
195220
)
196221
}

frontend/src/components/StatisticsModal.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const CustomTooltip = ({
3131
label,
3232
}: {
3333
active?: boolean;
34-
payload?: any[];
34+
payload?: { value: number; payload: EnhancedClickStats }[];
3535
label?: string;
3636
}) => {
3737
if (active && payload && payload.length > 0) {
@@ -81,12 +81,12 @@ export function StatisticsModal({ isOpen, onClose, linkId }: StatisticsModalProp
8181

8282
setClicksOverTime(enhancedClicksData);
8383
setSourcesData(sourcesData);
84-
} catch (error: any) {
84+
} catch (error: unknown) {
8585
console.error("Failed to fetch statistics:", error);
8686
toast({
8787
variant: "destructive",
8888
title: "Error",
89-
description: error.response?.data || "Failed to load statistics",
89+
description: error instanceof Error ? error.message : "Failed to load statistics",
9090
});
9191
} finally {
9292
setLoading(false);

frontend/vite.config.ts

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,32 @@ import react from '@vitejs/plugin-react'
33
import tailwindcss from '@tailwindcss/vite'
44
import path from "path"
55

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+
},
1316
},
14-
},
15-
},*/
16-
resolve: {
17-
alias: {
18-
"@": path.resolve(__dirname, "./src"),
19-
},
20-
},
21-
}))
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+
},
31+
},
32+
}
33+
}
34+
})

0 commit comments

Comments
 (0)