Initial implementation of Website Change Detection Monitor MVP
Features implemented: - Backend API with Express + TypeScript - User authentication (register/login with JWT) - Monitor CRUD operations with plan-based limits - Automated change detection engine - Email alert system - Frontend with Next.js + TypeScript - Dashboard with monitor management - Login/register pages - Monitor history viewer - PostgreSQL database schema - Docker setup for local development Technical stack: - Backend: Express, TypeScript, PostgreSQL, Redis (ready) - Frontend: Next.js 14, React Query, Tailwind CSS - Database: PostgreSQL with migrations - Services: Page fetching, diff detection, email alerts Documentation: - README with full setup instructions - SETUP guide for quick start - PROJECT_STATUS with current capabilities - Complete technical specifications Ready for local testing and feature expansion. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
253
frontend/app/dashboard/page.tsx
Normal file
253
frontend/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { monitorAPI } from '@/lib/api'
|
||||
import { isAuthenticated, clearAuth } from '@/lib/auth'
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter()
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newMonitor, setNewMonitor] = useState({
|
||||
url: '',
|
||||
name: '',
|
||||
frequency: 60,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated()) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [router])
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['monitors'],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.list()
|
||||
return response.monitors
|
||||
},
|
||||
})
|
||||
|
||||
const handleLogout = () => {
|
||||
clearAuth()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const handleAddMonitor = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await monitorAPI.create(newMonitor)
|
||||
setNewMonitor({ url: '', name: '', frequency: 60 })
|
||||
setShowAddForm(false)
|
||||
refetch()
|
||||
} catch (err) {
|
||||
console.error('Failed to create monitor:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckNow = async (id: string) => {
|
||||
try {
|
||||
await monitorAPI.check(id)
|
||||
alert('Check triggered! Results will appear shortly.')
|
||||
setTimeout(() => refetch(), 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger check:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this monitor?')) return
|
||||
|
||||
try {
|
||||
await monitorAPI.delete(id)
|
||||
refetch()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete monitor:', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const monitors = data || []
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="border-b bg-white">
|
||||
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Website Monitor</h1>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="rounded-md border px-4 py-2 text-sm hover:bg-gray-50"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Your Monitors</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
{monitors.length} monitor{monitors.length !== 1 ? 's' : ''} active
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="rounded-md bg-primary px-4 py-2 text-white hover:bg-primary/90"
|
||||
>
|
||||
+ Add Monitor
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add Monitor Form */}
|
||||
{showAddForm && (
|
||||
<div className="mb-6 rounded-lg bg-white p-6 shadow">
|
||||
<h3 className="mb-4 text-lg font-semibold">Add New Monitor</h3>
|
||||
<form onSubmit={handleAddMonitor} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={newMonitor.url}
|
||||
onChange={(e) =>
|
||||
setNewMonitor({ ...newMonitor, url: e.target.value })
|
||||
}
|
||||
placeholder="https://example.com"
|
||||
required
|
||||
className="mt-1 block w-full rounded-md border px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Name (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newMonitor.name}
|
||||
onChange={(e) =>
|
||||
setNewMonitor({ ...newMonitor, name: e.target.value })
|
||||
}
|
||||
placeholder="My Monitor"
|
||||
className="mt-1 block w-full rounded-md border px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Check Frequency (minutes)
|
||||
</label>
|
||||
<select
|
||||
value={newMonitor.frequency}
|
||||
onChange={(e) =>
|
||||
setNewMonitor({
|
||||
...newMonitor,
|
||||
frequency: parseInt(e.target.value),
|
||||
})
|
||||
}
|
||||
className="mt-1 block w-full rounded-md border px-3 py-2"
|
||||
>
|
||||
<option value={5}>Every 5 minutes</option>
|
||||
<option value={30}>Every 30 minutes</option>
|
||||
<option value={60}>Every hour</option>
|
||||
<option value={360}>Every 6 hours</option>
|
||||
<option value={1440}>Every 24 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-primary px-4 py-2 text-white hover:bg-primary/90"
|
||||
>
|
||||
Create Monitor
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddForm(false)}
|
||||
className="rounded-md border px-4 py-2 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monitors List */}
|
||||
{monitors.length === 0 ? (
|
||||
<div className="rounded-lg bg-white p-12 text-center shadow">
|
||||
<p className="mb-4 text-gray-600">No monitors yet</p>
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="rounded-md bg-primary px-4 py-2 text-white hover:bg-primary/90"
|
||||
>
|
||||
Create Your First Monitor
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{monitors.map((monitor: any) => (
|
||||
<div
|
||||
key={monitor.id}
|
||||
className="rounded-lg bg-white p-6 shadow hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">{monitor.name}</h3>
|
||||
<p className="text-sm text-gray-600 break-all">{monitor.url}</p>
|
||||
<div className="mt-2 flex gap-4 text-xs text-gray-500">
|
||||
<span>Every {monitor.frequency} min</span>
|
||||
<span className="capitalize">Status: {monitor.status}</span>
|
||||
{monitor.last_checked_at && (
|
||||
<span>
|
||||
Last checked:{' '}
|
||||
{new Date(monitor.last_checked_at).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleCheckNow(monitor.id)}
|
||||
className="rounded-md border px-3 py-1 text-sm hover:bg-gray-50"
|
||||
>
|
||||
Check Now
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push(`/monitors/${monitor.id}`)}
|
||||
className="rounded-md border px-3 py-1 text-sm hover:bg-gray-50"
|
||||
>
|
||||
History
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(monitor.id)}
|
||||
className="rounded-md border border-red-200 px-3 py-1 text-sm text-red-600 hover:bg-red-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
frontend/app/globals.css
Normal file
59
frontend/app/globals.css
Normal file
@@ -0,0 +1,59 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 48%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
25
frontend/app/layout.tsx
Normal file
25
frontend/app/layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { Providers } from './providers'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Website Monitor - Track Changes on Any Website',
|
||||
description: 'Monitor website changes with smart filtering and instant alerts',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
93
frontend/app/login/page.tsx
Normal file
93
frontend/app/login/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { authAPI } from '@/lib/api'
|
||||
import { saveAuth } from '@/lib/auth'
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const data = await authAPI.login(email, password)
|
||||
saveAuth(data.token, data.user)
|
||||
router.push('/dashboard')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to login')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="rounded-lg bg-white p-8 shadow-lg">
|
||||
<h1 className="mb-6 text-center text-3xl font-bold">Website Monitor</h1>
|
||||
<h2 className="mb-6 text-center text-xl text-gray-600">Sign In</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 p-4 text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-gray-600">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/register" className="font-medium text-primary hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
146
frontend/app/monitors/[id]/page.tsx
Normal file
146
frontend/app/monitors/[id]/page.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { monitorAPI } from '@/lib/api'
|
||||
import { isAuthenticated } from '@/lib/auth'
|
||||
|
||||
export default function MonitorHistoryPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const id = params?.id as string
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated()) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [router])
|
||||
|
||||
const { data: monitorData } = useQuery({
|
||||
queryKey: ['monitor', id],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.get(id)
|
||||
return response.monitor
|
||||
},
|
||||
})
|
||||
|
||||
const { data: historyData, isLoading } = useQuery({
|
||||
queryKey: ['history', id],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.history(id)
|
||||
return response.snapshots
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const snapshots = historyData || []
|
||||
const monitor = monitorData
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="border-b bg-white">
|
||||
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{monitor?.name || 'Monitor History'}
|
||||
</h1>
|
||||
{monitor && (
|
||||
<p className="text-sm text-gray-600 break-all">{monitor.url}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<h2 className="mb-4 text-xl font-semibold">Check History</h2>
|
||||
|
||||
{snapshots.length === 0 ? (
|
||||
<div className="rounded-lg bg-white p-12 text-center shadow">
|
||||
<p className="text-gray-600">No history yet</p>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
The first check will happen soon
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{snapshots.map((snapshot: any) => (
|
||||
<div
|
||||
key={snapshot.id}
|
||||
className={`rounded-lg bg-white p-4 shadow ${
|
||||
snapshot.changed ? 'border-l-4 border-l-blue-500' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${
|
||||
snapshot.changed
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{snapshot.changed ? 'Changed' : 'No Change'}
|
||||
</span>
|
||||
{snapshot.error_message && (
|
||||
<span className="rounded bg-red-100 px-2 py-1 text-xs font-medium text-red-800">
|
||||
Error
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-gray-600">
|
||||
{new Date(snapshot.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex gap-4 text-sm text-gray-600">
|
||||
<span>HTTP {snapshot.http_status}</span>
|
||||
<span>{snapshot.response_time}ms</span>
|
||||
{snapshot.change_percentage && (
|
||||
<span>{snapshot.change_percentage.toFixed(2)}% changed</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{snapshot.error_message && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
{snapshot.error_message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{snapshot.html_content && (
|
||||
<button
|
||||
onClick={() =>
|
||||
router.push(`/monitors/${id}/snapshot/${snapshot.id}`)
|
||||
}
|
||||
className="rounded-md border px-3 py-1 text-sm hover:bg-gray-50"
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
frontend/app/page.tsx
Normal file
26
frontend/app/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { isAuthenticated } from '@/lib/auth'
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated()) {
|
||||
router.push('/dashboard')
|
||||
} else {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold">Website Monitor</h1>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
frontend/app/providers.tsx
Normal file
22
frontend/app/providers.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
129
frontend/app/register/page.tsx
Normal file
129
frontend/app/register/page.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { authAPI } from '@/lib/api'
|
||||
import { saveAuth } from '@/lib/auth'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const data = await authAPI.register(email, password)
|
||||
saveAuth(data.token, data.user)
|
||||
router.push('/dashboard')
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.message || 'Failed to register'
|
||||
const details = err.response?.data?.details
|
||||
|
||||
if (details && Array.isArray(details)) {
|
||||
setError(details.join(', '))
|
||||
} else {
|
||||
setError(message)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="rounded-lg bg-white p-8 shadow-lg">
|
||||
<h1 className="mb-6 text-center text-3xl font-bold">Website Monitor</h1>
|
||||
<h2 className="mb-6 text-center text-xl text-gray-600">Create Account</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 p-4 text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
At least 8 characters with uppercase, lowercase, and number
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<Link href="/login" className="font-medium text-primary hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
90
frontend/lib/api.ts
Normal file
90
frontend/lib/api.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: `${API_URL}/api`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Add auth token to requests
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Handle auth errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Auth API
|
||||
export const authAPI = {
|
||||
register: async (email: string, password: string) => {
|
||||
const response = await api.post('/auth/register', { email, password });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
login: async (email: string, password: string) => {
|
||||
const response = await api.post('/auth/login', { email, password });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Monitor API
|
||||
export const monitorAPI = {
|
||||
list: async () => {
|
||||
const response = await api.get('/monitors');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (id: string) => {
|
||||
const response = await api.get(`/monitors/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: any) => {
|
||||
const response = await api.post('/monitors', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: any) => {
|
||||
const response = await api.put(`/monitors/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string) => {
|
||||
const response = await api.delete(`/monitors/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
check: async (id: string) => {
|
||||
const response = await api.post(`/monitors/${id}/check`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
history: async (id: string, limit = 50) => {
|
||||
const response = await api.get(`/monitors/${id}/history`, {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
snapshot: async (id: string, snapshotId: string) => {
|
||||
const response = await api.get(`/monitors/${id}/history/${snapshotId}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
29
frontend/lib/auth.ts
Normal file
29
frontend/lib/auth.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export function saveAuth(token: string, user: any) {
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
export function getAuth() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const userStr = localStorage.getItem('user');
|
||||
|
||||
if (!token || !userStr) return null;
|
||||
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
return { token, user };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
|
||||
export function isAuthenticated() {
|
||||
return !!getAuth();
|
||||
}
|
||||
8
frontend/next.config.js
Normal file
8
frontend/next.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
env: {
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001',
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
36
frontend/package.json
Normal file
36
frontend/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "website-monitor-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.0.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"@tanstack/react-query": "^5.17.9",
|
||||
"axios": "^1.6.5",
|
||||
"zod": "^3.22.4",
|
||||
"clsx": "^2.1.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"lucide-react": "^0.303.0",
|
||||
"date-fns": "^3.0.6",
|
||||
"react-diff-viewer-continued": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/react": "^18.2.46",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"typescript": "^5.3.3",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8.4.33",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "14.0.4"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
55
frontend/tailwind.config.ts
Normal file
55
frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
export default config
|
||||
27
frontend/tsconfig.json
Normal file
27
frontend/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user