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:
Timo
2026-01-16 18:46:40 +01:00
commit 2c1ec69a79
45 changed files with 5941 additions and 0 deletions

View 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
View 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
View 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>
)
}

View 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>
)
}

View 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
View 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>
)
}

View 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>
)
}

View 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
View 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
View 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
View 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
View 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"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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
View 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"]
}