Merge pull request #13 from WaveringAna/editLink

Add edit link functionality
This commit is contained in:
Wavering Ana 2025-02-02 00:07:38 -05:00 committed by GitHub
commit 8c4e30764e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 359 additions and 38 deletions

View file

@ -58,6 +58,12 @@ export const getAllLinks = async () => {
return response.data; 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) => { export const deleteLink = async (id: number) => {
await api.delete(`/links/${id}`); await api.delete(`/links/${id}`);
}; };

View file

@ -0,0 +1,139 @@
// 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>
);
}

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Link } from '../types/api' import { Link } from '../types/api'
import { getAllLinks, deleteLink } from '../api/client' import { getAllLinks, deleteLink } from '../api/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@ -12,7 +12,7 @@ import {
} from "@/components/ui/table" } from "@/components/ui/table"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { useToast } from "@/hooks/use-toast" import { useToast } from "@/hooks/use-toast"
import { Copy, Trash2, BarChart2 } from "lucide-react" import { Copy, Trash2, BarChart2, Pencil } from "lucide-react"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -23,6 +23,7 @@ import {
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { StatisticsModal } from "./StatisticsModal" import { StatisticsModal } from "./StatisticsModal"
import { EditModal } from './EditModal'
interface LinkListProps { interface LinkListProps {
refresh?: number; refresh?: number;
@ -39,27 +40,32 @@ export function LinkList({ refresh = 0 }: LinkListProps) {
isOpen: false, isOpen: false,
linkId: null, linkId: null,
}); });
const [editModal, setEditModal] = useState<{ isOpen: boolean; link: Link | null }>({
isOpen: false,
link: null,
});
const { toast } = useToast() const { toast } = useToast()
const fetchLinks = async () => { const fetchLinks = useCallback(async () => {
try { try {
setLoading(true) setLoading(true)
const data = await getAllLinks() const data = await getAllLinks()
setLinks(data) setLinks(data)
} catch (err) { } catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
toast({ toast({
title: "Error", title: "Error",
description: "Failed to load links", description: `Failed to load links: ${errorMessage}`,
variant: "destructive", variant: "destructive",
}) })
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }, [toast, setLinks, setLoading])
useEffect(() => { useEffect(() => {
fetchLinks() fetchLinks()
}, [refresh]) // Re-fetch when refresh counter changes }, [fetchLinks, refresh]) // Re-fetch when refresh counter changes
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteModal.linkId) return if (!deleteModal.linkId) return
@ -71,10 +77,11 @@ export function LinkList({ refresh = 0 }: LinkListProps) {
toast({ toast({
description: "Link deleted successfully", description: "Link deleted successfully",
}) })
} catch (err) { } catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
toast({ toast({
title: "Error", title: "Error",
description: "Failed to delete link", description: `Failed to delete link: ${errorMessage}`,
variant: "destructive", variant: "destructive",
}) })
} }
@ -127,6 +134,7 @@ export function LinkList({ refresh = 0 }: LinkListProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@ -134,7 +142,7 @@ export function LinkList({ refresh = 0 }: LinkListProps) {
<TableHead className="hidden md:table-cell">Original URL</TableHead> <TableHead className="hidden md:table-cell">Original URL</TableHead>
<TableHead>Clicks</TableHead> <TableHead>Clicks</TableHead>
<TableHead className="hidden md:table-cell">Created</TableHead> <TableHead className="hidden md:table-cell">Created</TableHead>
<TableHead>Actions</TableHead> <TableHead className="w-[1%] whitespace-nowrap pr-4">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -148,8 +156,8 @@ export function LinkList({ refresh = 0 }: LinkListProps) {
<TableCell className="hidden md:table-cell"> <TableCell className="hidden md:table-cell">
{new Date(link.created_at).toLocaleDateString()} {new Date(link.created_at).toLocaleDateString()}
</TableCell> </TableCell>
<TableCell> <TableCell className="p-2 pr-4">
<div className="flex gap-2"> <div className="flex items-center gap-1">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -168,6 +176,15 @@ export function LinkList({ refresh = 0 }: LinkListProps) {
<BarChart2 className="h-4 w-4" /> <BarChart2 className="h-4 w-4" />
<span className="sr-only">View statistics</span> <span className="sr-only">View statistics</span>
</Button> </Button>
<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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -191,6 +208,14 @@ export function LinkList({ refresh = 0 }: LinkListProps) {
onClose={() => setStatsModal({ isOpen: false, linkId: null })} onClose={() => setStatsModal({ isOpen: false, linkId: null })}
linkId={statsModal.linkId!} linkId={statsModal.linkId!}
/> />
{editModal.link && (
<EditModal
isOpen={editModal.isOpen}
onClose={() => setEditModal({ isOpen: false, link: null })}
link={editModal.link}
onSuccess={fetchLinks}
/>
)}
</> </>
) )
} }

View file

@ -31,7 +31,7 @@ const CustomTooltip = ({
label, label,
}: { }: {
active?: boolean; active?: boolean;
payload?: any[]; payload?: { value: number; payload: EnhancedClickStats }[];
label?: string; label?: string;
}) => { }) => {
if (active && payload && payload.length > 0) { if (active && payload && payload.length > 0) {
@ -81,12 +81,12 @@ export function StatisticsModal({ isOpen, onClose, linkId }: StatisticsModalProp
setClicksOverTime(enhancedClicksData); setClicksOverTime(enhancedClicksData);
setSourcesData(sourcesData); setSourcesData(sourcesData);
} catch (error: any) { } catch (error: unknown) {
console.error("Failed to fetch statistics:", error); console.error("Failed to fetch statistics:", error);
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Error", title: "Error",
description: error.response?.data || "Failed to load statistics", description: error instanceof Error ? error.message : "Failed to load statistics",
}); });
} finally { } finally {
setLoading(false); setLoading(false);

View file

@ -3,19 +3,32 @@ import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import path from "path" import path from "path"
export default defineConfig(() => ({ export default defineConfig(({ command }) => {
plugins: [react(), tailwindcss()], if (command === 'serve') { //command == 'dev'
/*server: { return {
server: {
proxy: { proxy: {
'/api': { '/api': {
target: process.env.VITE_API_URL || 'http://localhost:8080', target: process.env.VITE_API_URL || 'http://localhost:8080',
changeOrigin: true, changeOrigin: true,
}, },
}, },
},*/ },
plugins: [react(), tailwindcss()],
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),
}, },
}, },
})) }
} else { //command === 'build'
return {
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
}
}
})

View file

@ -457,12 +457,149 @@ pub async fn login(
})) }))
} }
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( pub async fn delete_link(
state: web::Data<AppState>, state: web::Data<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
path: web::Path<i32>, path: web::Path<i32>,
) -> Result<impl Responder, AppError> { ) -> Result<impl Responder, AppError> {
let link_id = path.into_inner(); let link_id: i32 = path.into_inner();
match &state.db { match &state.db {
DatabasePool::Postgres(pool) => { DatabasePool::Postgres(pool) => {

View file

@ -70,6 +70,7 @@ async fn main() -> Result<()> {
"/links/{id}/sources", "/links/{id}/sources",
web::get().to(handlers::get_link_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/register", web::post().to(handlers::register))
.route("/auth/login", web::post().to(handlers::login)) .route("/auth/login", web::post().to(handlers::login))
.route( .route(