feat: add frontend setup with Tailwind CSS
This commit is contained in:
parent
7fa0e9dd9d
commit
79f26fce25
28 changed files with 6724 additions and 520 deletions
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
76
frontend/src/app/globals.css
Normal file
76
frontend/src/app/globals.css
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
@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: 222.2 47.4% 11.2%;
|
||||
--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: 222.2 84% 4.9%;
|
||||
|
||||
--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: 210 40% 98%;
|
||||
--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: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
34
frontend/src/app/layout.tsx
Normal file
34
frontend/src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
26
frontend/src/app/page.tsx
Normal file
26
frontend/src/app/page.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
'use client'
|
||||
|
||||
import { ServerStatus } from "@/components/server-status"
|
||||
import { SearchForm } from "@/components/search-form"
|
||||
import { SearchResults } from "@/components/search-results"
|
||||
import { useState } from "react"
|
||||
|
||||
export default function Home() {
|
||||
const [searchResults, setSearchResults] = useState([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
|
||||
const services = [
|
||||
{ name: "Ollama", status: "running" as const },
|
||||
{ name: "SearxNG", status: "running" as const },
|
||||
{ name: "Supabase", status: "running" as const }
|
||||
]
|
||||
|
||||
return (
|
||||
<main className="container mx-auto p-4">
|
||||
<h1 className="text-4xl font-bold text-center mb-8">Business Search</h1>
|
||||
<SearchForm onSearch={setSearchResults} onSearchingChange={setIsSearching} />
|
||||
<SearchResults results={searchResults} isLoading={isSearching} />
|
||||
<ServerStatus services={services} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
79
frontend/src/components/search-form.tsx
Normal file
79
frontend/src/components/search-form.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { Search } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
interface SearchFormProps {
|
||||
onSearch: (results: any[]) => void;
|
||||
onSearchingChange: (isSearching: boolean) => void;
|
||||
}
|
||||
|
||||
export function SearchForm({ onSearch, onSearchingChange }: SearchFormProps) {
|
||||
const [query, setQuery] = useState("")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!query.trim()) return
|
||||
|
||||
setError(null)
|
||||
onSearchingChange(true)
|
||||
try {
|
||||
const response = await fetch("/api/search", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ query: query.trim() }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Search failed")
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
onSearch(data.results || [])
|
||||
|
||||
} catch (error) {
|
||||
console.error("Search error:", error)
|
||||
onSearch([])
|
||||
setError("Failed to perform search. Please try again.")
|
||||
} finally {
|
||||
onSearchingChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl mx-auto mt-8 mb-12">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="search" className="text-lg font-medium text-center">
|
||||
Find local businesses
|
||||
</label>
|
||||
<form onSubmit={handleSearch} className="relative">
|
||||
<input
|
||||
id="search"
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="e.g. plumbers in Denver, CO"
|
||||
className="w-full px-4 py-3 text-lg rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!query.trim()}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-3 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Search className="h-5 w-5" />
|
||||
</button>
|
||||
</form>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive text-center">{error}</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground text-center mt-2">
|
||||
Try searching for: restaurants, dentists, electricians, etc.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
frontend/src/components/search-results.tsx
Normal file
76
frontend/src/components/search-results.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
interface Business {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
website?: string;
|
||||
email?: string;
|
||||
description?: string;
|
||||
rating?: number;
|
||||
}
|
||||
|
||||
interface SearchResultsProps {
|
||||
results: Business[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function SearchResults({ results, isLoading }: SearchResultsProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto mt-8">
|
||||
<div className="animate-pulse space-y-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="bg-muted rounded-lg p-6">
|
||||
<div className="h-4 bg-muted-foreground/20 rounded w-3/4 mb-4"></div>
|
||||
<div className="h-3 bg-muted-foreground/20 rounded w-1/2"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!results.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto mt-8">
|
||||
<div className="space-y-4">
|
||||
{results.map((business) => (
|
||||
<div key={business.id} className="bg-card rounded-lg p-6 shadow-sm">
|
||||
<h3 className="text-xl font-semibold mb-2">{business.name}</h3>
|
||||
{business.address && (
|
||||
<p className="text-muted-foreground mb-2">{business.address}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
{business.phone && (
|
||||
<a
|
||||
href={`tel:${business.phone}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{business.phone}
|
||||
</a>
|
||||
)}
|
||||
{business.website && (
|
||||
<a
|
||||
href={business.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Visit Website
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{business.description && (
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
{business.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
frontend/src/components/server-status.tsx
Normal file
59
frontend/src/components/server-status.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { CheckCircle2, XCircle, AlertCircle } from "lucide-react"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
|
||||
interface ServiceStatus {
|
||||
name: string
|
||||
status: "running" | "error" | "warning"
|
||||
}
|
||||
|
||||
interface ServerStatusProps {
|
||||
services: ServiceStatus[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function ServerStatus({ services, error }: ServerStatusProps) {
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive" className="max-w-md mx-auto mt-4">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<AlertTitle>Server Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-md mx-auto mt-4">
|
||||
<h2 className="text-xl font-semibold text-center mb-6">Service Status</h2>
|
||||
<div className="space-y-3">
|
||||
{services.map((service) => (
|
||||
<Alert
|
||||
key={service.name}
|
||||
variant={service.status === "error" ? "destructive" : "default"}
|
||||
className="flex items-center justify-between hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{service.status === "running" && (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500 shrink-0" />
|
||||
)}
|
||||
{service.status === "error" && (
|
||||
<XCircle className="h-5 w-5 text-red-500 shrink-0" />
|
||||
)}
|
||||
{service.status === "warning" && (
|
||||
<AlertCircle className="h-5 w-5 text-yellow-500 shrink-0" />
|
||||
)}
|
||||
<AlertTitle className="font-medium">{service.name}</AlertTitle>
|
||||
</div>
|
||||
<span className={`text-sm ${
|
||||
service.status === "running" ? "text-green-600" :
|
||||
service.status === "error" ? "text-red-600" :
|
||||
"text-yellow-600"
|
||||
}`}>
|
||||
{service.status.charAt(0).toUpperCase() + service.status.slice(1)}
|
||||
</span>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
frontend/src/components/ui/alert.tsx
Normal file
58
frontend/src/components/ui/alert.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue