+25
-25
Cargo.lock
+25
-25
Cargo.lock
···
3
3
version = 4
4
4
5
5
[[package]]
6
-
name = "SimpleLink"
7
-
version = "0.1.0"
8
-
dependencies = [
9
-
"actix-cors",
10
-
"actix-web",
11
-
"anyhow",
12
-
"argon2",
13
-
"base62",
14
-
"chrono",
15
-
"clap",
16
-
"dotenv",
17
-
"jsonwebtoken",
18
-
"lazy_static",
19
-
"regex",
20
-
"serde",
21
-
"serde_json",
22
-
"sqlx",
23
-
"thiserror 1.0.69",
24
-
"tokio",
25
-
"tracing",
26
-
"tracing-subscriber",
27
-
"uuid",
28
-
]
29
-
30
-
[[package]]
31
6
name = "actix-codec"
32
7
version = "0.5.2"
33
8
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2037
2012
"num-traits",
2038
2013
"thiserror 2.0.11",
2039
2014
"time",
2015
+
]
2016
+
2017
+
[[package]]
2018
+
name = "simplelink"
2019
+
version = "0.1.0"
2020
+
dependencies = [
2021
+
"actix-cors",
2022
+
"actix-web",
2023
+
"anyhow",
2024
+
"argon2",
2025
+
"base62",
2026
+
"chrono",
2027
+
"clap",
2028
+
"dotenv",
2029
+
"jsonwebtoken",
2030
+
"lazy_static",
2031
+
"regex",
2032
+
"serde",
2033
+
"serde_json",
2034
+
"sqlx",
2035
+
"thiserror 1.0.69",
2036
+
"tokio",
2037
+
"tracing",
2038
+
"tracing-subscriber",
2039
+
"uuid",
2040
2040
]
2041
2041
2042
2042
[[package]]
+2
-2
Cargo.toml
+2
-2
Cargo.toml
+45
Dockerfile
+45
Dockerfile
···
1
+
# Build stage
2
+
FROM rust:latest as builder
3
+
4
+
# Install PostgreSQL client libraries and SSL dependencies
5
+
RUN apt-get update && \
6
+
apt-get install -y pkg-config libssl-dev libpq-dev && \
7
+
rm -rf /var/lib/apt/lists/*
8
+
9
+
WORKDIR /usr/src/app
10
+
11
+
# Copy manifests first (better layer caching)
12
+
COPY Cargo.toml Cargo.lock ./
13
+
14
+
# Copy source code and SQLx prepared queries
15
+
COPY src/ src/
16
+
COPY migrations/ migrations/
17
+
COPY .sqlx/ .sqlx/
18
+
19
+
# Build your application
20
+
RUN cargo build --release
21
+
22
+
# Runtime stage
23
+
FROM debian:bookworm-slim
24
+
25
+
# Install runtime dependencies
26
+
RUN apt-get update && \
27
+
apt-get install -y libpq5 ca-certificates openssl libssl3 && \
28
+
rm -rf /var/lib/apt/lists/*
29
+
30
+
WORKDIR /app
31
+
32
+
# Copy the binary from builder
33
+
COPY --from=builder /usr/src/app/target/release/simplelink /app/simplelink
34
+
# Copy migrations folder for SQLx
35
+
COPY --from=builder /usr/src/app/migrations /app/migrations
36
+
37
+
# Expose the port (this is just documentation)
38
+
EXPOSE 8080
39
+
40
+
# Set default network configuration
41
+
ENV SERVER_HOST=0.0.0.0
42
+
ENV SERVER_PORT=8080
43
+
44
+
# Run the binary
45
+
CMD ["./simplelink"]
+29
-24
frontend/src/App.tsx
+29
-24
frontend/src/App.tsx
···
1
1
import { ThemeProvider } from "@/components/theme-provider"
2
-
import { Button } from './components/ui/button'
3
2
import { LinkForm } from './components/LinkForm'
4
3
import { LinkList } from './components/LinkList'
5
4
import { AuthForms } from './components/AuthForms'
6
5
import { AuthProvider, useAuth } from './context/AuthContext'
6
+
import { Button } from "@/components/ui/button"
7
+
import { Toaster } from './components/ui/toaster'
8
+
import { ModeToggle } from './components/mode-toggle'
7
9
import { useState } from 'react'
8
-
import { Toaster } from './components/ui/toaster'
9
10
10
11
function AppContent() {
11
12
const { user, logout } = useAuth()
12
13
const [refreshCounter, setRefreshCounter] = useState(0)
13
14
14
15
const handleLinkCreated = () => {
15
-
// Increment refresh counter to trigger list refresh
16
16
setRefreshCounter(prev => prev + 1)
17
17
}
18
18
19
19
return (
20
-
<div className="min-h-screen flex flex-col">
21
-
<div className="container max-w-6xl mx-auto py-8 flex-1 flex flex-col">
22
-
<div className="space-y-8 flex-1 flex flex-col justify-center">
23
-
<div className="flex items-center justify-between">
24
-
<h1 className="text-3xl font-bold">SimpleLink</h1>
20
+
<div className="min-h-screen bg-background flex flex-col">
21
+
<header className="border-b">
22
+
<div className="container max-w-6xl mx-auto flex h-16 items-center justify-between px-4">
23
+
<h1 className="text-2xl font-bold">SimpleLink</h1>
24
+
<div className="flex items-center gap-4">
25
25
{user ? (
26
-
<div className="flex items-center gap-4">
27
-
<p className="text-sm text-muted-foreground">Welcome, {user.email}</p>
28
-
<Button variant="outline" onClick={logout}>
26
+
<>
27
+
<span className="text-sm text-muted-foreground">Welcome, {user.email}</span>
28
+
<Button variant="outline" size="sm" onClick={logout}>
29
29
Logout
30
30
</Button>
31
-
</div>
31
+
</>
32
32
) : (
33
-
<div className="flex items-center gap-4">
34
-
<p className="text-sm text-muted-foreground">A link shortening and tracking service</p>
35
-
</div>
33
+
<span className="text-sm text-muted-foreground">A link shortening and tracking service</span>
36
34
)}
35
+
<ModeToggle />
37
36
</div>
37
+
</div>
38
+
</header>
38
39
39
-
{user ? (
40
-
<>
41
-
<LinkForm onSuccess={handleLinkCreated} />
42
-
<LinkList refresh={refreshCounter} />
43
-
</>
44
-
) : (
45
-
<AuthForms />
46
-
)}
40
+
<main className="flex-1 flex flex-col">
41
+
<div className="container max-w-6xl mx-auto px-4 py-8 flex-1 flex flex-col">
42
+
<div className="space-y-8 flex-1 flex flex-col justify-center">
43
+
{user ? (
44
+
<>
45
+
<LinkForm onSuccess={handleLinkCreated} />
46
+
<LinkList refresh={refreshCounter} />
47
+
</>
48
+
) : (
49
+
<AuthForms />
50
+
)}
51
+
</div>
47
52
</div>
48
-
</div>
53
+
</main>
49
54
</div>
50
55
)
51
56
}
+1
-1
frontend/src/components/AuthForms.tsx
+1
-1
frontend/src/components/AuthForms.tsx
···
56
56
57
57
return (
58
58
<Card className="w-full max-w-md mx-auto p-6">
59
-
<Tabs value={activeTab} onValueChange={(value: 'login' | 'register') => setActiveTab(value)}>
59
+
<Tabs value={activeTab} onValueChange={(value: string) => setActiveTab(value as 'login' | 'register')}>
60
60
<TabsList className="grid w-full grid-cols-2">
61
61
<TabsTrigger value="login">Login</TabsTrigger>
62
62
<TabsTrigger value="register">Register</TabsTrigger>
+50
-43
frontend/src/components/LinkForm.tsx
+50
-43
frontend/src/components/LinkForm.tsx
···
2
2
import { useForm } from 'react-hook-form'
3
3
import { zodResolver } from '@hookform/resolvers/zod'
4
4
import * as z from 'zod'
5
-
import { CreateLinkRequest, Link } from '../types/api'
5
+
import { CreateLinkRequest } from '../types/api'
6
6
import { createShortLink } from '../api/client'
7
7
import { Button } from "@/components/ui/button"
8
+
import { Input } from "@/components/ui/input"
9
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
10
+
import { LinkIcon } from "lucide-react"
8
11
import {
9
12
Form,
10
13
FormControl,
11
14
FormField,
12
-
FormItem,
13
15
FormLabel,
14
16
FormMessage,
15
17
} from "@/components/ui/form"
16
-
import { Input } from "@/components/ui/input"
17
18
import { useToast } from "@/hooks/use-toast"
18
19
19
20
const formSchema = z.object({
···
29
30
})
30
31
31
32
interface LinkFormProps {
32
-
onSuccess: (link: Link) => void
33
+
onSuccess: () => void;
33
34
}
34
35
35
36
export function LinkForm({ onSuccess }: LinkFormProps) {
···
47
48
const onSubmit = async (values: z.infer<typeof formSchema>) => {
48
49
try {
49
50
setLoading(true)
50
-
const link = await createShortLink(values as CreateLinkRequest)
51
+
await createShortLink(values as CreateLinkRequest)
51
52
form.reset()
52
-
onSuccess(link)
53
+
onSuccess() // Call the onSuccess callback to trigger refresh
53
54
toast({
54
55
description: "Short link created successfully",
55
56
})
···
65
66
}
66
67
67
68
return (
68
-
<div className="max-w-[500px] mx-auto">
69
-
<Form {...form}>
70
-
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
71
-
<FormField
72
-
control={form.control}
73
-
name="url"
74
-
render={({ field }) => (
75
-
<FormItem>
76
-
<FormLabel>URL</FormLabel>
77
-
<FormControl>
78
-
<Input placeholder="https://example.com" {...field} />
79
-
</FormControl>
80
-
<FormMessage />
81
-
</FormItem>
82
-
)}
83
-
/>
69
+
<Card className="mb-8">
70
+
<CardHeader>
71
+
<CardTitle>Create Short Link</CardTitle>
72
+
<CardDescription>Enter a URL to generate a shortened link</CardDescription>
73
+
</CardHeader>
74
+
<CardContent>
75
+
<Form {...form}>
76
+
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4 md:flex-row md:items-end">
77
+
<FormField
78
+
control={form.control}
79
+
name="url"
80
+
render={({ field }) => (
81
+
<div className="flex-1 space-y-2">
82
+
<FormLabel>URL</FormLabel>
83
+
<FormControl>
84
+
<div className="relative">
85
+
<LinkIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
86
+
<Input placeholder="https://example.com" className="pl-9" {...field} />
87
+
</div>
88
+
</FormControl>
89
+
<FormMessage />
90
+
</div>
91
+
)}
92
+
/>
84
93
85
-
<FormField
86
-
control={form.control}
87
-
name="custom_code"
88
-
render={({ field }) => (
89
-
<FormItem>
90
-
<FormLabel>Custom Code (optional)</FormLabel>
91
-
<FormControl>
92
-
<Input placeholder="example" {...field} />
93
-
</FormControl>
94
-
<FormMessage />
95
-
</FormItem>
96
-
)}
97
-
/>
94
+
<FormField
95
+
control={form.control}
96
+
name="custom_code"
97
+
render={({ field }) => (
98
+
<div className="w-full md:w-1/4 space-y-2">
99
+
<FormLabel>Custom Code <span className="text-muted-foreground">(optional)</span></FormLabel>
100
+
<FormControl>
101
+
<Input placeholder="custom-code" {...field} />
102
+
</FormControl>
103
+
<FormMessage />
104
+
</div>
105
+
)}
106
+
/>
98
107
99
-
<div className="flex justify-end">
100
-
<Button type="submit" disabled={loading}>
108
+
<Button type="submit" disabled={loading} className="md:w-auto">
101
109
{loading ? "Creating..." : "Create Short Link"}
102
110
</Button>
103
-
</div>
104
-
</form>
105
-
</Form>
106
-
</div>
111
+
</form>
112
+
</Form>
113
+
</CardContent>
114
+
</Card>
107
115
)
108
-
}
109
-
116
+
}
+74
-51
frontend/src/components/LinkList.tsx
+74
-51
frontend/src/components/LinkList.tsx
···
1
1
import { useEffect, useState } from 'react'
2
2
import { Link } from '../types/api'
3
3
import { getAllLinks, deleteLink } from '../api/client'
4
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
4
5
import {
5
6
Table,
6
7
TableBody,
···
9
10
TableHeader,
10
11
TableRow,
11
12
} from "@/components/ui/table"
13
+
import { Button } from "@/components/ui/button"
14
+
import { useToast } from "@/hooks/use-toast"
15
+
import { Copy, Trash2 } from "lucide-react"
12
16
import {
13
17
Dialog,
14
18
DialogContent,
···
17
21
DialogDescription,
18
22
DialogFooter,
19
23
} from "@/components/ui/dialog"
20
-
import { Button } from "@/components/ui/button"
21
-
import { useToast } from "@/hooks/use-toast"
22
24
23
25
interface LinkListProps {
24
-
refresh: number
26
+
refresh?: number;
25
27
}
26
28
27
-
export function LinkList({ refresh }: LinkListProps) {
29
+
export function LinkList({ refresh = 0 }: LinkListProps) {
28
30
const [links, setLinks] = useState<Link[]>([])
29
31
const [loading, setLoading] = useState(true)
30
32
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; linkId: number | null }>({
···
51
53
52
54
useEffect(() => {
53
55
fetchLinks()
54
-
}, [refresh])
56
+
}, [refresh]) // Re-fetch when refresh counter changes
55
57
56
58
const handleDelete = async () => {
57
59
if (!deleteModal.linkId) return
···
61
63
await fetchLinks()
62
64
setDeleteModal({ isOpen: false, linkId: null })
63
65
toast({
64
-
title: "Link deleted",
65
-
description: "The link has been successfully deleted.",
66
+
description: "Link deleted successfully",
66
67
})
67
68
} catch (err) {
68
69
toast({
···
73
74
}
74
75
}
75
76
77
+
const handleCopy = (shortCode: string) => {
78
+
navigator.clipboard.writeText(`http://localhost:8080/${shortCode}`)
79
+
toast({
80
+
description: "Link copied to clipboard",
81
+
})
82
+
}
83
+
76
84
if (loading && !links.length) {
77
85
return <div className="text-center py-4">Loading...</div>
78
86
}
79
87
80
88
return (
81
-
<div className="space-y-4">
89
+
<>
82
90
<Dialog open={deleteModal.isOpen} onOpenChange={(open) => setDeleteModal({ isOpen: open, linkId: null })}>
83
91
<DialogContent>
84
92
<DialogHeader>
···
98
106
</DialogContent>
99
107
</Dialog>
100
108
101
-
<Table>
102
-
<TableHeader>
103
-
<TableRow>
104
-
<TableHead>Short Code</TableHead>
105
-
<TableHead>Original URL</TableHead>
106
-
<TableHead>Clicks</TableHead>
107
-
<TableHead>Created</TableHead>
108
-
<TableHead>Actions</TableHead>
109
-
</TableRow>
110
-
</TableHeader>
111
-
<TableBody>
112
-
{links.map((link) => (
113
-
<TableRow key={link.id}>
114
-
<TableCell>{link.short_code}</TableCell>
115
-
<TableCell className="max-w-[300px] truncate">{link.original_url}</TableCell>
116
-
<TableCell>{link.clicks}</TableCell>
117
-
<TableCell>{new Date(link.created_at).toLocaleDateString()}</TableCell>
118
-
<TableCell>
119
-
<div className="flex gap-2">
120
-
<Button
121
-
variant="secondary"
122
-
size="sm"
123
-
onClick={() => {
124
-
navigator.clipboard.writeText(`http://localhost:8080/${link.short_code}`)
125
-
toast({ description: "Link copied to clipboard" })
126
-
}}
127
-
>
128
-
Copy
129
-
</Button>
130
-
<Button
131
-
variant="destructive"
132
-
size="sm"
133
-
onClick={() => setDeleteModal({ isOpen: true, linkId: link.id })}
134
-
>
135
-
Delete
136
-
</Button>
137
-
</div>
138
-
</TableCell>
139
-
</TableRow>
140
-
))}
141
-
</TableBody>
142
-
</Table>
143
-
</div>
109
+
<Card>
110
+
<CardHeader>
111
+
<CardTitle>Your Links</CardTitle>
112
+
<CardDescription>Manage and track your shortened links</CardDescription>
113
+
</CardHeader>
114
+
<CardContent>
115
+
<div className="rounded-md border">
116
+
<Table>
117
+
<TableHeader>
118
+
<TableRow>
119
+
<TableHead>Short Code</TableHead>
120
+
<TableHead className="hidden md:table-cell">Original URL</TableHead>
121
+
<TableHead>Clicks</TableHead>
122
+
<TableHead className="hidden md:table-cell">Created</TableHead>
123
+
<TableHead>Actions</TableHead>
124
+
</TableRow>
125
+
</TableHeader>
126
+
<TableBody>
127
+
{links.map((link) => (
128
+
<TableRow key={link.id}>
129
+
<TableCell className="font-medium">{link.short_code}</TableCell>
130
+
<TableCell className="hidden md:table-cell max-w-[300px] truncate">
131
+
{link.original_url}
132
+
</TableCell>
133
+
<TableCell>{link.clicks}</TableCell>
134
+
<TableCell className="hidden md:table-cell">
135
+
{new Date(link.created_at).toLocaleDateString()}
136
+
</TableCell>
137
+
<TableCell>
138
+
<div className="flex gap-2">
139
+
<Button
140
+
variant="ghost"
141
+
size="icon"
142
+
className="h-8 w-8"
143
+
onClick={() => handleCopy(link.short_code)}
144
+
>
145
+
<Copy className="h-4 w-4" />
146
+
<span className="sr-only">Copy link</span>
147
+
</Button>
148
+
<Button
149
+
variant="ghost"
150
+
size="icon"
151
+
className="h-8 w-8 text-destructive"
152
+
onClick={() => setDeleteModal({ isOpen: true, linkId: link.id })}
153
+
>
154
+
<Trash2 className="h-4 w-4" />
155
+
<span className="sr-only">Delete link</span>
156
+
</Button>
157
+
</div>
158
+
</TableCell>
159
+
</TableRow>
160
+
))}
161
+
</TableBody>
162
+
</Table>
163
+
</div>
164
+
</CardContent>
165
+
</Card>
166
+
</>
144
167
)
145
168
}
+4
-2
src/main.rs
+4
-2
src/main.rs
···
28
28
29
29
let state = AppState { db: pool };
30
30
31
-
info!("Starting server at http://127.0.0.1:8080");
31
+
let host = std::env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
32
+
let port = std::env::var("SERVER_PORT").unwrap_or_else(|_| "8080".to_string());
33
+
info!("Starting server at http://{}:{}", host, port);
32
34
33
35
// Start HTTP server
34
36
HttpServer::new(move || {
···
54
56
})
55
57
.workers(2)
56
58
.backlog(10_000)
57
-
.bind("127.0.0.1:8080")?
59
+
.bind(format!("{}:{}", host, port))?
58
60
.run()
59
61
.await?;
60
62