A very performant and light (2mb in memory) link shortener and tracker. Written in Rust and React and uses Postgres/SQLite.

Merge pull request #13 from WaveringAna/editLink

Add edit link functionality

authored by nekomimi.pet and committed by GitHub 8c4e3076 2f7147de

Changed files
+359 -38
frontend
src
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 70 70 "/links/{id}/sources", 71 71 web::get().to(handlers::get_link_sources), 72 72 ) 73 + .route("/links/{id}", web::patch().to(handlers::edit_link)) 73 74 .route("/auth/register", web::post().to(handlers::register)) 74 75 .route("/auth/login", web::post().to(handlers::login)) 75 76 .route(