mirror of
https://github.com/arkorty/Reduce.git
synced 2026-03-18 00:47:10 +00:00
feat: version 2
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
56
frontend/src/components/Navbar.tsx
Normal file
56
frontend/src/components/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
frontend/src/context/AuthContext.tsx
Normal file
72
frontend/src/context/AuthContext.tsx
Normal 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
15
frontend/src/lib/api.ts
Normal 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;
|
||||
@@ -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 & 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>
|
||||
|
||||
418
frontend/src/pages/Links.tsx
Normal file
418
frontend/src/pages/Links.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
frontend/src/pages/Login.tsx
Normal file
79
frontend/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
98
frontend/src/pages/Register.tsx
Normal file
98
frontend/src/pages/Register.tsx
Normal 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 (3–32 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user