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

Changed files
+359 -38
frontend
src
+6
frontend/src/api/client.ts
···
return response.data;
};
export const deleteLink = async (id: number) => {
await api.delete(`/links/${id}`);
};
···
return response.data;
};
+
export const editLink = async (id: number, data: Partial<CreateLinkRequest>) => {
+
const response = await api.patch<Link>(`/links/${id}`, data);
+
return response.data;
+
};
+
+
export const deleteLink = async (id: number) => {
await api.delete(`/links/${id}`);
};
+139
frontend/src/components/EditModal.tsx
···
···
+
// src/components/EditModal.tsx
+
import { useState } from 'react';
+
import { useForm } from 'react-hook-form';
+
import { zodResolver } from '@hookform/resolvers/zod';
+
import * as z from 'zod';
+
import { Link } from '../types/api';
+
import { editLink } from '../api/client';
+
import { useToast } from '@/hooks/use-toast';
+
import {
+
Dialog,
+
DialogContent,
+
DialogHeader,
+
DialogTitle,
+
DialogFooter,
+
} from '@/components/ui/dialog';
+
import { Button } from '@/components/ui/button';
+
import { Input } from '@/components/ui/input';
+
import {
+
Form,
+
FormControl,
+
FormField,
+
FormItem,
+
FormLabel,
+
FormMessage,
+
} from '@/components/ui/form';
+
+
const formSchema = z.object({
+
url: z
+
.string()
+
.min(1, 'URL is required')
+
.url('Must be a valid URL')
+
.refine((val) => val.startsWith('http://') || val.startsWith('https://'), {
+
message: 'URL must start with http:// or https://',
+
}),
+
custom_code: z
+
.string()
+
.regex(/^[a-zA-Z0-9_-]{1,32}$/, {
+
message:
+
'Custom code must be 1-32 characters and contain only letters, numbers, underscores, and hyphens',
+
})
+
.optional(),
+
});
+
+
interface EditModalProps {
+
isOpen: boolean;
+
onClose: () => void;
+
link: Link;
+
onSuccess: () => void;
+
}
+
+
export function EditModal({ isOpen, onClose, link, onSuccess }: EditModalProps) {
+
const [loading, setLoading] = useState(false);
+
const { toast } = useToast();
+
+
const form = useForm<z.infer<typeof formSchema>>({
+
resolver: zodResolver(formSchema),
+
defaultValues: {
+
url: link.original_url,
+
custom_code: link.short_code,
+
},
+
});
+
+
const onSubmit = async (values: z.infer<typeof formSchema>) => {
+
try {
+
setLoading(true);
+
await editLink(link.id, values);
+
toast({
+
description: 'Link updated successfully',
+
});
+
onSuccess();
+
onClose();
+
} catch (err: unknown) {
+
const error = err as { response?: { data?: { error?: string } } };
+
toast({
+
variant: 'destructive',
+
title: 'Error',
+
description: error.response?.data?.error || 'Failed to update link',
+
});
+
} finally {
+
setLoading(false);
+
}
+
};
+
+
return (
+
<Dialog open={isOpen} onOpenChange={onClose}>
+
<DialogContent>
+
<DialogHeader>
+
<DialogTitle>Edit Link</DialogTitle>
+
</DialogHeader>
+
+
<Form {...form}>
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+
<FormField
+
control={form.control}
+
name="url"
+
render={({ field }) => (
+
<FormItem>
+
<FormLabel>Destination URL</FormLabel>
+
<FormControl>
+
<Input placeholder="https://example.com" {...field} />
+
</FormControl>
+
<FormMessage />
+
</FormItem>
+
)}
+
/>
+
+
<FormField
+
control={form.control}
+
name="custom_code"
+
render={({ field }) => (
+
<FormItem>
+
<FormLabel>Short Code</FormLabel>
+
<FormControl>
+
<Input placeholder="custom-code" {...field} />
+
</FormControl>
+
<FormMessage />
+
</FormItem>
+
)}
+
/>
+
+
<DialogFooter>
+
<Button
+
type="button"
+
variant="outline"
+
onClick={onClose}
+
disabled={loading}
+
>
+
Cancel
+
</Button>
+
<Button type="submit" disabled={loading}>
+
{loading ? 'Saving...' : 'Save Changes'}
+
</Button>
+
</DialogFooter>
+
</form>
+
</Form>
+
</DialogContent>
+
</Dialog>
+
);
+
}
+44 -19
frontend/src/components/LinkList.tsx
···
-
import { useEffect, useState } from 'react'
import { Link } from '../types/api'
import { getAllLinks, deleteLink } from '../api/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
···
} from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { useToast } from "@/hooks/use-toast"
-
import { Copy, Trash2, BarChart2 } from "lucide-react"
import {
Dialog,
DialogContent,
···
} from "@/components/ui/dialog"
import { StatisticsModal } from "./StatisticsModal"
interface LinkListProps {
refresh?: number;
···
isOpen: false,
linkId: null,
});
const { toast } = useToast()
-
const fetchLinks = async () => {
try {
setLoading(true)
const data = await getAllLinks()
setLinks(data)
-
} catch (err) {
toast({
title: "Error",
-
description: "Failed to load links",
variant: "destructive",
})
} finally {
setLoading(false)
}
-
}
useEffect(() => {
fetchLinks()
-
}, [refresh]) // Re-fetch when refresh counter changes
const handleDelete = async () => {
if (!deleteModal.linkId) return
···
toast({
description: "Link deleted successfully",
})
-
} catch (err) {
toast({
title: "Error",
-
description: "Failed to delete link",
variant: "destructive",
})
}
···
const baseUrl = window.location.origin
navigator.clipboard.writeText(`${baseUrl}/${shortCode}`)
toast({
-
description: (
-
<>
-
Link copied to clipboard
-
<br />
-
You can add ?source=TextHere to the end of the link to track the source of clicks
-
</>
-
),
})
}
···
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
···
<TableHead className="hidden md:table-cell">Original URL</TableHead>
<TableHead>Clicks</TableHead>
<TableHead className="hidden md:table-cell">Created</TableHead>
-
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
···
<TableCell className="hidden md:table-cell">
{new Date(link.created_at).toLocaleDateString()}
</TableCell>
-
<TableCell>
-
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
···
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => setDeleteModal({ isOpen: true, linkId: link.id })}
>
···
onClose={() => setStatsModal({ isOpen: false, linkId: null })}
linkId={statsModal.linkId!}
/>
</>
)
}
···
+
import { useCallback, useEffect, useState } from 'react'
import { Link } from '../types/api'
import { getAllLinks, deleteLink } from '../api/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
···
} from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { useToast } from "@/hooks/use-toast"
+
import { Copy, Trash2, BarChart2, Pencil } from "lucide-react"
import {
Dialog,
DialogContent,
···
} from "@/components/ui/dialog"
import { StatisticsModal } from "./StatisticsModal"
+
import { EditModal } from './EditModal'
interface LinkListProps {
refresh?: number;
···
isOpen: false,
linkId: null,
});
+
const [editModal, setEditModal] = useState<{ isOpen: boolean; link: Link | null }>({
+
isOpen: false,
+
link: null,
+
});
const { toast } = useToast()
+
const fetchLinks = useCallback(async () => {
try {
setLoading(true)
const data = await getAllLinks()
setLinks(data)
+
} catch (err: unknown) {
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
toast({
title: "Error",
+
description: `Failed to load links: ${errorMessage}`,
variant: "destructive",
})
} finally {
setLoading(false)
}
+
}, [toast, setLinks, setLoading])
useEffect(() => {
fetchLinks()
+
}, [fetchLinks, refresh]) // Re-fetch when refresh counter changes
const handleDelete = async () => {
if (!deleteModal.linkId) return
···
toast({
description: "Link deleted successfully",
})
+
} catch (err: unknown) {
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
toast({
title: "Error",
+
description: `Failed to delete link: ${errorMessage}`,
variant: "destructive",
})
}
···
const baseUrl = window.location.origin
navigator.clipboard.writeText(`${baseUrl}/${shortCode}`)
toast({
+
description: (
+
<>
+
Link copied to clipboard
+
<br />
+
You can add ?source=TextHere to the end of the link to track the source of clicks
+
</>
+
),
})
}
···
</CardHeader>
<CardContent>
<div className="rounded-md border">
+
<Table>
<TableHeader>
<TableRow>
···
<TableHead className="hidden md:table-cell">Original URL</TableHead>
<TableHead>Clicks</TableHead>
<TableHead className="hidden md:table-cell">Created</TableHead>
+
<TableHead className="w-[1%] whitespace-nowrap pr-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
···
<TableCell className="hidden md:table-cell">
{new Date(link.created_at).toLocaleDateString()}
</TableCell>
+
<TableCell className="p-2 pr-4">
+
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
···
<Button
variant="ghost"
size="icon"
+
className="h-8 w-8"
+
onClick={() => setEditModal({ isOpen: true, link })}
+
>
+
<Pencil className="h-4 w-4" />
+
<span className="sr-only">Edit Link</span>
+
</Button>
+
<Button
+
variant="ghost"
+
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => setDeleteModal({ isOpen: true, linkId: link.id })}
>
···
onClose={() => setStatsModal({ isOpen: false, linkId: null })}
linkId={statsModal.linkId!}
/>
+
{editModal.link && (
+
<EditModal
+
isOpen={editModal.isOpen}
+
onClose={() => setEditModal({ isOpen: false, link: null })}
+
link={editModal.link}
+
onSuccess={fetchLinks}
+
/>
+
)}
</>
)
}
+3 -3
frontend/src/components/StatisticsModal.tsx
···
label,
}: {
active?: boolean;
-
payload?: any[];
label?: string;
}) => {
if (active && payload && payload.length > 0) {
···
setClicksOverTime(enhancedClicksData);
setSourcesData(sourcesData);
-
} catch (error: any) {
console.error("Failed to fetch statistics:", error);
toast({
variant: "destructive",
title: "Error",
-
description: error.response?.data || "Failed to load statistics",
});
} finally {
setLoading(false);
···
label,
}: {
active?: boolean;
+
payload?: { value: number; payload: EnhancedClickStats }[];
label?: string;
}) => {
if (active && payload && payload.length > 0) {
···
setClicksOverTime(enhancedClicksData);
setSourcesData(sourcesData);
+
} catch (error: unknown) {
console.error("Failed to fetch statistics:", error);
toast({
variant: "destructive",
title: "Error",
+
description: error instanceof Error ? error.message : "Failed to load statistics",
});
} finally {
setLoading(false);
+28 -15
frontend/vite.config.ts
···
import tailwindcss from '@tailwindcss/vite'
import path from "path"
-
export default defineConfig(() => ({
-
plugins: [react(), tailwindcss()],
-
/*server: {
-
proxy: {
-
'/api': {
-
target: process.env.VITE_API_URL || 'http://localhost:8080',
-
changeOrigin: true,
},
-
},
-
},*/
-
resolve: {
-
alias: {
-
"@": path.resolve(__dirname, "./src"),
-
},
-
},
-
}))
···
import tailwindcss from '@tailwindcss/vite'
import path from "path"
+
export default defineConfig(({ command }) => {
+
if (command === 'serve') { //command == 'dev'
+
return {
+
server: {
+
proxy: {
+
'/api': {
+
target: process.env.VITE_API_URL || 'http://localhost:8080',
+
changeOrigin: true,
+
},
+
},
+
},
+
plugins: [react(), tailwindcss()],
+
resolve: {
+
alias: {
+
"@": path.resolve(__dirname, "./src"),
+
},
+
},
+
}
+
} else { //command === 'build'
+
return {
+
plugins: [react(), tailwindcss()],
+
resolve: {
+
alias: {
+
"@": path.resolve(__dirname, "./src"),
+
},
},
+
}
+
}
+
})
+138 -1
src/handlers.rs
···
}))
}
pub async fn delete_link(
state: web::Data<AppState>,
user: AuthenticatedUser,
path: web::Path<i32>,
) -> Result<impl Responder, AppError> {
-
let link_id = path.into_inner();
match &state.db {
DatabasePool::Postgres(pool) => {
···
}))
}
+
pub async fn edit_link(
+
state: web::Data<AppState>,
+
user: AuthenticatedUser,
+
path: web::Path<i32>,
+
payload: web::Json<CreateLink>,
+
) -> Result<impl Responder, AppError> {
+
let link_id: i32 = path.into_inner();
+
+
// Validate the new URL if provided
+
validate_url(&payload.url)?;
+
+
// Validate custom code if provided
+
if let Some(ref custom_code) = payload.custom_code {
+
validate_custom_code(custom_code)?;
+
+
// Check if the custom code is already taken by another link
+
let existing_link = match &state.db {
+
DatabasePool::Postgres(pool) => {
+
sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = $1 AND id != $2")
+
.bind(custom_code)
+
.bind(link_id)
+
.fetch_optional(pool)
+
.await?
+
}
+
DatabasePool::Sqlite(pool) => {
+
sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = ?1 AND id != ?2")
+
.bind(custom_code)
+
.bind(link_id)
+
.fetch_optional(pool)
+
.await?
+
}
+
};
+
+
if existing_link.is_some() {
+
return Err(AppError::InvalidInput(
+
"Custom code already taken".to_string(),
+
));
+
}
+
}
+
+
// Update the link
+
let updated_link = match &state.db {
+
DatabasePool::Postgres(pool) => {
+
let mut tx = pool.begin().await?;
+
+
// First verify the link belongs to the user
+
let link =
+
sqlx::query_as::<_, Link>("SELECT * FROM links WHERE id = $1 AND user_id = $2")
+
.bind(link_id)
+
.bind(user.user_id)
+
.fetch_optional(&mut *tx)
+
.await?;
+
+
if link.is_none() {
+
return Err(AppError::NotFound);
+
}
+
+
// Update the link
+
let updated = sqlx::query_as::<_, Link>(
+
r#"
+
UPDATE links
+
SET
+
original_url = $1,
+
short_code = COALESCE($2, short_code)
+
WHERE id = $3 AND user_id = $4
+
RETURNING *
+
"#,
+
)
+
.bind(&payload.url)
+
.bind(&payload.custom_code)
+
.bind(link_id)
+
.bind(user.user_id)
+
.fetch_one(&mut *tx)
+
.await?;
+
+
// If source is provided, add a click record
+
if let Some(ref source) = payload.source {
+
sqlx::query("INSERT INTO clicks (link_id, source) VALUES ($1, $2)")
+
.bind(link_id)
+
.bind(source)
+
.execute(&mut *tx)
+
.await?;
+
}
+
+
tx.commit().await?;
+
updated
+
}
+
DatabasePool::Sqlite(pool) => {
+
let mut tx = pool.begin().await?;
+
+
// First verify the link belongs to the user
+
let link =
+
sqlx::query_as::<_, Link>("SELECT * FROM links WHERE id = ?1 AND user_id = ?2")
+
.bind(link_id)
+
.bind(user.user_id)
+
.fetch_optional(&mut *tx)
+
.await?;
+
+
if link.is_none() {
+
return Err(AppError::NotFound);
+
}
+
+
// Update the link
+
let updated = sqlx::query_as::<_, Link>(
+
r#"
+
UPDATE links
+
SET
+
original_url = ?1,
+
short_code = COALESCE(?2, short_code)
+
WHERE id = ?3 AND user_id = ?4
+
RETURNING *
+
"#,
+
)
+
.bind(&payload.url)
+
.bind(&payload.custom_code)
+
.bind(link_id)
+
.bind(user.user_id)
+
.fetch_one(&mut *tx)
+
.await?;
+
+
// If source is provided, add a click record
+
if let Some(ref source) = payload.source {
+
sqlx::query("INSERT INTO clicks (link_id, source) VALUES (?1, ?2)")
+
.bind(link_id)
+
.bind(source)
+
.execute(&mut *tx)
+
.await?;
+
}
+
+
tx.commit().await?;
+
updated
+
}
+
};
+
+
Ok(HttpResponse::Ok().json(updated_link))
+
}
+
pub async fn delete_link(
state: web::Data<AppState>,
user: AuthenticatedUser,
path: web::Path<i32>,
) -> Result<impl Responder, AppError> {
+
let link_id: i32 = path.into_inner();
match &state.db {
DatabasePool::Postgres(pool) => {
+1
src/main.rs
···
"/links/{id}/sources",
web::get().to(handlers::get_link_sources),
)
.route("/auth/register", web::post().to(handlers::register))
.route("/auth/login", web::post().to(handlers::login))
.route(
···
"/links/{id}/sources",
web::get().to(handlers::get_link_sources),
)
+
.route("/links/{id}", web::patch().to(handlers::edit_link))
.route("/auth/register", web::post().to(handlers::register))
.route("/auth/login", web::post().to(handlers::login))
.route(