feat: version 2

This commit is contained in:
Arkaprabha Chakraborty
2026-02-12 05:51:56 +05:30
parent 1ecd710191
commit 005838045a
20 changed files with 1645 additions and 334 deletions

View File

@@ -1,15 +1,26 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import Navbar from './components/Navbar';
import Home from './pages/Home';
import Redirect from './pages/Redirect';
import Login from './pages/Login';
import Register from './pages/Register';
import Links from './pages/Links';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/:code" element={<Redirect />} />
</Routes>
</BrowserRouter>
<AuthProvider>
<BrowserRouter>
<Navbar />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/links" element={<Links />} />
<Route path="/:code" element={<Redirect />} />
</Routes>
</BrowserRouter>
</AuthProvider>
);
}

View File

@@ -0,0 +1,56 @@
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { MdPerson, MdLogout, MdLink } from 'react-icons/md';
export default function Navbar() {
const { user, logout } = useAuth();
return (
<nav className="fixed top-0 w-full bg-zinc-950/80 backdrop-blur border-b border-zinc-800 z-50">
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
<Link to="/" className="text-zinc-100 font-bold tracking-widest uppercase text-sm">
Reduce
</Link>
<div className="flex items-center gap-4">
{user ? (
<>
<Link
to="/links"
className="flex items-center gap-1.5 text-zinc-400 hover:text-zinc-100 transition-colors text-sm"
>
<MdLink size={16} />
Links
</Link>
<span className="text-zinc-600 text-xs font-mono flex items-center gap-1">
<MdPerson size={14} />
{user.username}
</span>
<button
onClick={logout}
className="text-zinc-500 hover:text-red-400 transition-colors"
title="Logout"
>
<MdLogout size={16} />
</button>
</>
) : (
<>
<Link
to="/login"
className="text-zinc-400 hover:text-zinc-100 transition-colors text-sm"
>
Login
</Link>
<Link
to="/register"
className="bg-zinc-200 text-zinc-900 px-3 py-1.5 text-xs font-bold uppercase tracking-widest hover:bg-white transition-colors"
>
Register
</Link>
</>
)}
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,72 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import api from '../lib/api';
interface User {
id: number;
username: string;
}
interface AuthContextType {
user: User | null;
token: string | null;
login: (username: string, password: string) => Promise<void>;
register: (username: string, password: string) => Promise<void>;
logout: () => void;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (token) {
api
.get('/auth/me')
.then((res) => setUser(res.data))
.catch(() => {
localStorage.removeItem('token');
setToken(null);
setUser(null);
})
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
}
}, [token]);
const login = async (username: string, password: string) => {
const res = await api.post('/auth/login', { username, password });
localStorage.setItem('token', res.data.token);
setToken(res.data.token);
setUser(res.data.user);
};
const register = async (username: string, password: string) => {
const res = await api.post('/auth/register', { username, password });
localStorage.setItem('token', res.data.token);
setToken(res.data.token);
setUser(res.data.user);
};
const logout = () => {
localStorage.removeItem('token');
setToken(null);
setUser(null);
};
return (
<AuthContext.Provider value={{ user, token, login, register, logout, isLoading }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}

15
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,15 @@
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080',
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default api;

View File

@@ -1,52 +1,68 @@
import { useState } from 'react';
import axios from 'axios';
import { MdContentCopy, MdCheck, MdRefresh } from 'react-icons/md';
import { MdContentCopy, MdCheck, MdRefresh, MdLock, MdTune } from 'react-icons/md';
import QRCode from 'react-qr-code';
import { useAuth } from '../context/AuthContext';
import api from '../lib/api';
export default function Home() {
const [longUrl, setLongUrl] = useState("");
const [shortUrl, setShortUrl] = useState("");
const { user } = useAuth();
const [longUrl, setLongUrl] = useState('');
const [shortUrl, setShortUrl] = useState('');
const [copied, setCopied] = useState(false);
const [prevLongUrl, setPrevLongUrl] = useState("");
const [status, setStatus] = useState<{ type: 'error' | 'success' | 'idle', msg: string }>({ type: 'idle', msg: '' });
const [prevLongUrl, setPrevLongUrl] = useState('');
const [status, setStatus] = useState<{ type: 'error' | 'success' | 'idle'; msg: string }>({
type: 'idle',
msg: '',
});
// Advanced options (logged-in users)
const [showAdvanced, setShowAdvanced] = useState(false);
const [customCode, setCustomCode] = useState('');
const [requiresAuth, setRequiresAuth] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setStatus({ type: 'idle', msg: '' });
if (longUrl === prevLongUrl && shortUrl) {
if (longUrl === prevLongUrl && shortUrl) return;
if (longUrl.trim() === '') return;
try {
new URL(longUrl);
} catch {
setStatus({ type: 'error', msg: 'Invalid URL' });
return;
}
if (longUrl.trim() === "") {
return;
if (!user && requiresAuth) {
setStatus({ type: 'error', msg: 'Login required for protected links' });
return;
}
try {
new URL(longUrl);
} catch (_) {
setStatus({ type: 'error', msg: 'Invalid URL' });
return;
}
const baseURL = window.location.origin;
const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080';
// Use r.webark.in for short links in production, otherwise use current origin
const baseURL = window.location.hostname === 'r.webark.in'
? 'https://r.webark.in'
: window.location.origin;
try {
const response = await axios.post(
`${backendUrl}/reduce/shorten`,
{
lurl: longUrl,
base_url: baseURL,
},
);
const payload: Record<string, any> = {
lurl: longUrl,
base_url: baseURL,
};
if (user && customCode) payload.code = customCode;
if (user && requiresAuth) {
payload.requires_auth = true;
}
const response = await api.post('/reduce/shorten', payload);
setShortUrl(response.data.surl);
setPrevLongUrl(longUrl);
setStatus({ type: 'success', msg: '' });
} catch (error) {
console.error("Error shortening URL:", error);
setStatus({ type: 'error', msg: 'Error' });
} catch (error: any) {
const msg = error.response?.data?.message || 'Error';
setStatus({ type: 'error', msg });
}
};
@@ -57,80 +73,143 @@ export default function Home() {
};
const handleReset = () => {
setLongUrl("");
setShortUrl("");
setPrevLongUrl("");
setLongUrl('');
setShortUrl('');
setPrevLongUrl('');
setStatus({ type: 'idle', msg: '' });
setCopied(false);
setCustomCode('');
setRequiresAuth(false);
};
return (
<div className="flex flex-col items-center justify-center min-h-screen p-4">
<div className="flex flex-col items-center justify-center min-h-screen p-4 pt-20">
<div className="w-full max-w-md">
{/* Header */}
<header className="mb-8 text-center">
<h1 className="text-3xl font-bold tracking-widest text-zinc-100 uppercase">
Reduce
</h1>
<h1 className="text-3xl font-bold tracking-widest text-zinc-100 uppercase">Reduce</h1>
{!user && (
<p className="text-zinc-600 text-xs mt-2 font-mono">
Login to create custom codes &amp; manage links
</p>
)}
</header>
{/* Main Interface */}
<div className="bg-zinc-900 border border-zinc-800 p-6 md:p-8">
{!shortUrl ? (
<>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
type="text"
placeholder="https://..."
value={longUrl}
onChange={(e) => setLongUrl(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm"
/>
<button
type="submit"
className="w-full bg-zinc-200 text-zinc-950 font-bold uppercase tracking-widest py-3 hover:bg-white transition-colors border border-transparent active:border-zinc-400 active:scale-[0.99]"
>
Reduce
</button>
</form>
{/* Error Indicator */}
{status.msg && status.type === 'error' && (
<div className="mt-4 text-xs font-mono uppercase tracking-wide border-l-2 pl-3 py-1 border-red-500 text-red-400">
{status.msg}
</div>
)}
</>
) : (
<div className="flex flex-col gap-6">
<div className="flex gap-2">
<input
readOnly
value={shortUrl}
className="w-full bg-zinc-950 border border-zinc-700 p-3 text-emerald-400 font-mono text-sm focus:outline-none"
/>
<button
onClick={handleCopy}
className="bg-zinc-800 border border-zinc-700 text-zinc-300 p-3 hover:bg-zinc-700 hover:text-white transition-colors"
aria-label="Copy"
>
{copied ? <MdCheck size={20} /> : <MdContentCopy size={20} />}
</button>
</div>
<div className="flex justify-center bg-white p-4">
<QRCode value={shortUrl} size={150} style={{ height: "auto", maxWidth: "100%", width: "100%" }} />
</div>
{!shortUrl ? (
<>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
type="text"
placeholder="https://..."
value={longUrl}
onChange={(e) => setLongUrl(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm"
/>
{/* Advanced options for logged-in users */}
{user && (
<>
<button
onClick={handleReset}
className="w-full bg-zinc-800 text-zinc-300 font-bold uppercase tracking-widest py-3 hover:bg-zinc-700 hover:text-white transition-colors border border-transparent flex items-center justify-center gap-2"
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-1.5 text-zinc-600 hover:text-zinc-400 transition-colors text-xs uppercase tracking-wider self-start"
>
<MdRefresh size={20} /> Use Again
<MdTune size={14} />
{showAdvanced ? 'Hide options' : 'Options'}
</button>
{showAdvanced && (
<div className="flex flex-col gap-3 border-t border-zinc-800 pt-3">
<input
type="text"
placeholder="Custom short code (optional)"
value={customCode}
onChange={(e) => setCustomCode(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm"
minLength={2}
maxLength={32}
/>
<label className="flex items-center gap-3 cursor-pointer group">
<div
className={`w-9 h-5 rounded-full relative transition-colors ${
requiresAuth ? 'bg-amber-500' : 'bg-zinc-700'
}`}
onClick={() => setRequiresAuth(!requiresAuth)}
>
<div
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
requiresAuth ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</div>
<span className="text-zinc-500 text-xs uppercase tracking-wider flex items-center gap-1 group-hover:text-zinc-300 transition-colors">
<MdLock size={12} /> Protected
</span>
</label>
{requiresAuth && (
<div className="pl-4 border-l-2 border-amber-900/50">
<p className="text-zinc-600 text-xs">
Visitors will need to enter your account credentials to access this link.
</p>
</div>
)}
</div>
)}
</>
)}
<button
type="submit"
className="w-full bg-zinc-200 text-zinc-950 font-bold uppercase tracking-widest py-3 hover:bg-white transition-colors border border-transparent active:border-zinc-400 active:scale-[0.99]"
>
Reduce
</button>
</form>
{/* Error Indicator */}
{status.msg && status.type === 'error' && (
<div className="mt-4 text-xs font-mono uppercase tracking-wide border-l-2 pl-3 py-1 border-red-500 text-red-400">
{status.msg}
</div>
)}
)}
</>
) : (
<div className="flex flex-col gap-6">
<div className="flex gap-2">
<input
readOnly
value={shortUrl}
className="w-full bg-zinc-950 border border-zinc-700 p-3 text-emerald-400 font-mono text-sm focus:outline-none"
/>
<button
onClick={handleCopy}
className="bg-zinc-800 border border-zinc-700 text-zinc-300 p-3 hover:bg-zinc-700 hover:text-white transition-colors"
aria-label="Copy"
>
{copied ? <MdCheck size={20} /> : <MdContentCopy size={20} />}
</button>
</div>
<div className="flex justify-center bg-white p-4">
<QRCode
value={shortUrl}
size={150}
style={{ height: 'auto', maxWidth: '100%', width: '100%' }}
/>
</div>
<button
onClick={handleReset}
className="w-full bg-zinc-800 text-zinc-300 font-bold uppercase tracking-widest py-3 hover:bg-zinc-700 hover:text-white transition-colors border border-transparent flex items-center justify-center gap-2"
>
<MdRefresh size={20} /> Use Again
</button>
</div>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,418 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../lib/api';
import {
MdAdd,
MdEdit,
MdDelete,
MdContentCopy,
MdCheck,
MdLock,
MdClose,
MdOpenInNew,
MdLink,
} from 'react-icons/md';
interface LinkItem {
id: number;
user_id: number | null;
code: string;
long_url: string;
is_custom: boolean;
requires_auth: boolean;
access_username: string;
click_count: number;
created_at: string;
updated_at: string;
}
interface ModalState {
open: boolean;
mode: 'create' | 'edit';
link?: LinkItem;
}
export default function Links() {
const { user, isLoading } = useAuth();
const navigate = useNavigate();
const [links, setLinks] = useState<LinkItem[]>([]);
const [loading, setLoading] = useState(true);
const [copiedId, setCopiedId] = useState<number | null>(null);
const [modal, setModal] = useState<ModalState>({ open: false, mode: 'create' });
const [error, setError] = useState('');
useEffect(() => {
if (!isLoading && !user) {
navigate('/login');
}
}, [user, isLoading, navigate]);
useEffect(() => {
if (user) fetchLinks();
}, [user]);
const fetchLinks = async () => {
try {
const res = await api.get('/links');
setLinks(res.data || []);
} catch {
setError('Failed to load links');
} finally {
setLoading(false);
}
};
const handleDelete = async (id: number) => {
if (!window.confirm('Delete this link permanently?')) return;
try {
await api.delete(`/links/${id}`);
setLinks((prev) => prev.filter((l) => l.id !== id));
} catch {
setError('Failed to delete link');
}
};
const handleCopy = (code: string, id: number) => {
const url = `${window.location.origin}/${code}`;
navigator.clipboard.writeText(url);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
};
const baseUrl = window.location.origin;
if (isLoading || loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="font-mono text-zinc-500 text-sm tracking-widest animate-pulse">Loading...</div>
</div>
);
}
return (
<div className="min-h-screen pt-20 pb-12 px-4">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl font-bold tracking-widest text-zinc-100 uppercase">Your Links</h1>
<button
onClick={() => setModal({ open: true, mode: 'create' })}
className="flex items-center gap-1.5 bg-zinc-200 text-zinc-900 px-4 py-2 text-xs font-bold uppercase tracking-widest hover:bg-white transition-colors"
>
<MdAdd size={16} /> New Link
</button>
</div>
{error && (
<div className="mb-6 text-xs font-mono uppercase tracking-wide border-l-2 pl-3 py-1 border-red-500 text-red-400">
{error}
</div>
)}
{/* Links */}
{links.length === 0 ? (
<div className="bg-zinc-900 border border-zinc-800 p-12 text-center">
<MdLink size={40} className="mx-auto mb-4 text-zinc-700" />
<p className="text-zinc-500 font-mono text-sm mb-4">No links yet</p>
<button
onClick={() => setModal({ open: true, mode: 'create' })}
className="text-zinc-400 hover:text-zinc-100 transition-colors text-sm underline"
>
Create your first link
</button>
</div>
) : (
<div className="flex flex-col gap-3">
{links.map((link) => (
<div
key={link.id}
className="bg-zinc-900 border border-zinc-800 p-4 md:p-5 flex flex-col md:flex-row md:items-center gap-3"
>
{/* Link info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-emerald-400 font-mono text-sm truncate">
{baseUrl}/{link.code}
</span>
{link.is_custom && (
<span className="text-[10px] uppercase tracking-wider bg-zinc-800 text-zinc-400 px-1.5 py-0.5 border border-zinc-700">
Custom
</span>
)}
{link.requires_auth && (
<span className="text-[10px] uppercase tracking-wider bg-amber-950/50 text-amber-400 px-1.5 py-0.5 border border-amber-900/50 flex items-center gap-0.5">
<MdLock size={10} /> Protected
</span>
)}
</div>
<p className="text-zinc-500 text-xs font-mono truncate">{link.long_url}</p>
<p className="text-zinc-700 text-xs mt-1">
{link.click_count} click{link.click_count !== 1 ? 's' : ''} ·{' '}
{new Date(link.created_at).toLocaleDateString()}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-1.5 shrink-0">
<button
onClick={() => handleCopy(link.code, link.id)}
className="p-2 text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800 transition-colors"
title="Copy short URL"
>
{copiedId === link.id ? <MdCheck size={16} /> : <MdContentCopy size={16} />}
</button>
<a
href={link.long_url}
target="_blank"
rel="noopener noreferrer"
className="p-2 text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800 transition-colors"
title="Open long URL"
>
<MdOpenInNew size={16} />
</a>
<button
onClick={() => setModal({ open: true, mode: 'edit', link })}
className="p-2 text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800 transition-colors"
title="Edit"
>
<MdEdit size={16} />
</button>
<button
onClick={() => handleDelete(link.id)}
className="p-2 text-zinc-500 hover:text-red-400 hover:bg-zinc-800 transition-colors"
title="Delete"
>
<MdDelete size={16} />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Modal */}
{modal.open && (
<LinkModal
mode={modal.mode}
link={modal.link}
onClose={() => setModal({ open: false, mode: 'create' })}
onSaved={fetchLinks}
/>
)}
</div>
);
}
// ---------- Link Create/Edit Modal ----------
interface LinkModalProps {
mode: 'create' | 'edit';
link?: LinkItem;
onClose: () => void;
onSaved: () => void;
}
function LinkModal({ mode, link, onClose, onSaved }: LinkModalProps) {
const { user } = useAuth();
const [longUrl, setLongUrl] = useState(link?.long_url || '');
const [code, setCode] = useState(link?.code || '');
const [requiresAuth, setRequiresAuth] = useState(link?.requires_auth || false);
const [useOwnCredentials, setUseOwnCredentials] = useState(true);
const [accessUsername, setAccessUsername] = useState(link?.access_username || '');
const [accessPassword, setAccessPassword] = useState('');
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
// If editing a link with custom credentials (not matching user), default to custom mode
useEffect(() => {
if (link && link.access_username && link.access_username !== user?.username) {
setUseOwnCredentials(false);
}
}, [link, user]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSaving(true);
try {
if (mode === 'create') {
// Use r.webark.in for short links in production, otherwise use current origin
const baseURL = window.location.hostname === 'r.webark.in'
? 'https://r.webark.in'
: window.location.origin;
await api.post('/reduce/shorten', {
lurl: longUrl,
base_url: baseURL,
code: code || undefined,
requires_auth: requiresAuth,
access_username: requiresAuth && accessUsername ? accessUsername : undefined,
access_password: requiresAuth && accessPassword ? accessPassword : undefined,
});
} else if (link) {
const body: Record<string, any> = {};
if (code !== link.code) body.code = code;
if (longUrl !== link.long_url) body.long_url = longUrl;
if (requiresAuth !== link.requires_auth) body.requires_auth = requiresAuth;
if (requiresAuth) {
if (accessUsername) body.access_username = accessUsername;
if (accessPassword) body.access_password = accessPassword;
}
await api.put(`/links/${link.id}`, body);
}
onSaved();
onClose();
} catch (err: any) {
setError(err.response?.data?.message || 'Something went wrong');
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-zinc-900 border border-zinc-800 w-full max-w-md">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800">
<h2 className="text-sm font-bold uppercase tracking-widest text-zinc-100">
{mode === 'create' ? 'New Link' : 'Edit Link'}
</h2>
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-200 transition-colors">
<MdClose size={20} />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 flex flex-col gap-4">
<div>
<label className="block text-zinc-500 text-xs uppercase tracking-wider mb-1.5">
Long URL
</label>
<input
type="url"
placeholder="https://..."
value={longUrl}
onChange={(e) => setLongUrl(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm"
required
/>
</div>
<div>
<label className="block text-zinc-500 text-xs uppercase tracking-wider mb-1.5">
Short Code <span className="text-zinc-700">(optional)</span>
</label>
<input
type="text"
placeholder="my-custom-code"
value={code}
onChange={(e) => setCode(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm"
minLength={2}
maxLength={32}
/>
</div>
{/* Requires Auth Toggle */}
<label className="flex items-center gap-3 cursor-pointer group">
<div
className={`w-9 h-5 rounded-full relative transition-colors ${
requiresAuth ? 'bg-amber-500' : 'bg-zinc-700'
}`}
onClick={() => setRequiresAuth(!requiresAuth)}
>
<div
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
requiresAuth ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</div>
<span className="text-zinc-400 text-xs uppercase tracking-wider flex items-center gap-1.5 group-hover:text-zinc-200 transition-colors">
<MdLock size={14} /> Require authentication
</span>
</label>
{/* Auth credentials */}
{requiresAuth && (
<div className="flex flex-col gap-3 pl-4 border-l-2 border-amber-900/50">
<label className="flex items-center gap-2 cursor-pointer group text-xs">
<input
type="checkbox"
checked={useOwnCredentials}
onChange={(e) => setUseOwnCredentials(e.target.checked)}
className="w-3.5 h-3.5"
/>
<span className="text-zinc-500 group-hover:text-zinc-300 transition-colors">
Use my account credentials
</span>
</label>
{useOwnCredentials ? (
<p className="text-zinc-600 text-xs">
Visitors will need to enter your account username and password to access this link.
</p>
) : (
<>
<div>
<label className="block text-zinc-500 text-xs uppercase tracking-wider mb-1.5">
Access Username
</label>
<input
type="text"
placeholder="visitor_username"
value={accessUsername}
onChange={(e) => setAccessUsername(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm"
required={requiresAuth && !useOwnCredentials}
/>
</div>
<div>
<label className="block text-zinc-500 text-xs uppercase tracking-wider mb-1.5">
Access Password{' '}
{mode === 'edit' && (
<span className="text-zinc-700">(leave blank to keep current)</span>
)}
</label>
<input
type="password"
placeholder="••••••••"
value={accessPassword}
onChange={(e) => setAccessPassword(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm"
required={mode === 'create' && requiresAuth && !useOwnCredentials}
/>
</div>
</>
)}
</div>
)}
{error && (
<div className="text-xs font-mono uppercase tracking-wide border-l-2 pl-3 py-1 border-red-500 text-red-400">
{error}
</div>
)}
<div className="flex gap-3 mt-2">
<button
type="submit"
disabled={saving}
className="flex-1 bg-zinc-200 text-zinc-950 font-bold uppercase tracking-widest py-3 text-sm hover:bg-white transition-colors disabled:opacity-50"
>
{saving ? 'Saving...' : mode === 'create' ? 'Create' : 'Save'}
</button>
<button
type="button"
onClick={onClose}
className="px-6 py-3 bg-zinc-800 text-zinc-400 font-bold uppercase tracking-widest text-sm hover:bg-zinc-700 hover:text-zinc-200 transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login, user } = useAuth();
const navigate = useNavigate();
if (user) {
navigate('/links');
return null;
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(username, password);
navigate('/links');
} catch (err: any) {
setError(err.response?.data?.message || 'Login failed');
} finally {
setLoading(false);
}
};
return (
<div className="flex flex-col items-center justify-center min-h-screen p-4 pt-20">
<div className="w-full max-w-sm">
<h1 className="text-2xl font-bold tracking-widest text-zinc-100 uppercase mb-8 text-center">
Login
</h1>
<div className="bg-zinc-900 border border-zinc-800 p-6 md:p-8">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm"
required
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm"
required
/>
<button
type="submit"
disabled={loading}
className="w-full bg-zinc-200 text-zinc-950 font-bold uppercase tracking-widest py-3 hover:bg-white transition-colors disabled:opacity-50"
>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
{error && (
<div className="mt-4 text-xs font-mono uppercase tracking-wide border-l-2 pl-3 py-1 border-red-500 text-red-400">
{error}
</div>
)}
<p className="mt-6 text-center text-zinc-600 text-sm">
No account?{' '}
<Link to="/register" className="text-zinc-400 hover:text-zinc-100 transition-colors">
Register
</Link>
</p>
</div>
</div>
</div>
);
}

View File

@@ -1,29 +1,31 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import axios from 'axios';
import { MdErrorOutline } from "react-icons/md";
import { MdErrorOutline, MdLock } from 'react-icons/md';
import api from '../lib/api';
export default function Redirect() {
const { code } = useParams();
const [error, setError] = useState(false);
const [needsAuth, setNeedsAuth] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [authError, setAuthError] = useState('');
const [verifying, setVerifying] = useState(false);
useEffect(() => {
const fetchUrl = async () => {
if (!code) return;
const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080';
try {
const response = await axios.get(
`${backendUrl}/reduce/${code}`
);
if (response.status === 200 && response.data.lurl) {
const response = await api.get(`/reduce/${code}`);
if (response.data.requires_auth) {
setNeedsAuth(true);
} else if (response.data.lurl) {
window.location.replace(response.data.lurl);
} else {
setError(true);
}
} catch (err) {
console.error("Redirect error:", err);
} catch {
setError(true);
}
};
@@ -31,32 +33,101 @@ export default function Redirect() {
fetchUrl();
}, [code]);
const handleVerify = async (e: React.FormEvent) => {
e.preventDefault();
setAuthError('');
setVerifying(true);
try {
const res = await api.post(`/reduce/${code}/verify`, { username, password });
if (res.data.lurl) {
window.location.replace(res.data.lurl);
} else {
setAuthError('Unexpected response');
}
} catch (err: any) {
setAuthError(err.response?.data?.message || 'Invalid credentials');
} finally {
setVerifying(false);
}
};
if (error) {
return (
<div className="flex items-center justify-center min-h-screen p-4">
<div className="bg-zinc-900 border border-zinc-800 p-8 max-w-md w-full text-center">
<div className="flex justify-center mb-6">
<MdErrorOutline className="text-red-500 text-5xl" />
</div>
<h1 className="text-xl font-bold mb-4 text-zinc-100 uppercase tracking-widest">404 Not Found</h1>
<p className="text-zinc-500 font-mono text-sm mb-8">
Link invalid or expired.
</p>
<a href="/" className="inline-block w-full">
<span className="block bg-zinc-200 text-zinc-900 font-bold uppercase tracking-widest py-3 hover:bg-white transition-colors border border-transparent">
Go Home
</span>
</a>
<div className="flex items-center justify-center min-h-screen p-4">
<div className="bg-zinc-900 border border-zinc-800 p-8 max-w-md w-full text-center">
<div className="flex justify-center mb-6">
<MdErrorOutline className="text-red-500 text-5xl" />
</div>
<h1 className="text-xl font-bold mb-4 text-zinc-100 uppercase tracking-widest">
404 Not Found
</h1>
<p className="text-zinc-500 font-mono text-sm mb-8">Link invalid or expired.</p>
<a href="/" className="inline-block w-full">
<span className="block bg-zinc-200 text-zinc-900 font-bold uppercase tracking-widest py-3 hover:bg-white transition-colors border border-transparent">
Go Home
</span>
</a>
</div>
</div>
);
}
if (needsAuth) {
return (
<div className="flex items-center justify-center min-h-screen p-4">
<div className="bg-zinc-900 border border-zinc-800 p-8 max-w-sm w-full">
<div className="flex justify-center mb-6">
<div className="bg-amber-950/30 border border-amber-900/50 rounded-full p-3">
<MdLock className="text-amber-400 text-2xl" />
</div>
</div>
<h1 className="text-lg font-bold mb-1 text-zinc-100 uppercase tracking-widest text-center">
Protected Link
</h1>
<p className="text-zinc-600 font-mono text-xs mb-6 text-center">
Enter credentials to continue
</p>
<form onSubmit={handleVerify} className="flex flex-col gap-4">
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm"
required
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm"
required
/>
<button
type="submit"
disabled={verifying}
className="w-full bg-zinc-200 text-zinc-950 font-bold uppercase tracking-widest py-3 hover:bg-white transition-colors disabled:opacity-50"
>
{verifying ? 'Verifying...' : 'Continue'}
</button>
</form>
{authError && (
<div className="mt-4 text-xs font-mono uppercase tracking-wide border-l-2 pl-3 py-1 border-red-500 text-red-400">
{authError}
</div>
)}
</div>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<div className="font-mono text-zinc-400 text-sm tracking-widest animate-pulse">
Redirecting...
</div>
<div className="font-mono text-zinc-400 text-sm tracking-widest animate-pulse">
Redirecting...
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function Register() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { register, user } = useAuth();
const navigate = useNavigate();
if (user) {
navigate('/links');
return null;
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (password !== confirm) {
setError('Passwords do not match');
return;
}
setLoading(true);
try {
await register(username, password);
navigate('/links');
} catch (err: any) {
setError(err.response?.data?.message || 'Registration failed');
} finally {
setLoading(false);
}
};
return (
<div className="flex flex-col items-center justify-center min-h-screen p-4 pt-20">
<div className="w-full max-w-sm">
<h1 className="text-2xl font-bold tracking-widest text-zinc-100 uppercase mb-8 text-center">
Register
</h1>
<div className="bg-zinc-900 border border-zinc-800 p-6 md:p-8">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
type="text"
placeholder="Username (332 chars)"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm"
required
minLength={3}
maxLength={32}
/>
<input
type="password"
placeholder="Password (min 6 chars)"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm"
required
minLength={6}
/>
<input
type="password"
placeholder="Confirm password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm"
required
minLength={6}
/>
<button
type="submit"
disabled={loading}
className="w-full bg-zinc-200 text-zinc-950 font-bold uppercase tracking-widest py-3 hover:bg-white transition-colors disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Register'}
</button>
</form>
{error && (
<div className="mt-4 text-xs font-mono uppercase tracking-wide border-l-2 pl-3 py-1 border-red-500 text-red-400">
{error}
</div>
)}
<p className="mt-6 text-center text-zinc-600 text-sm">
Already have an account?{' '}
<Link to="/login" className="text-zinc-400 hover:text-zinc-100 transition-colors">
Login
</Link>
</p>
</div>
</div>
</div>
);
}