Spaces:
Sleeping
Sleeping
Commit ·
0dd2082
0
Parent(s):
Initial commit of RedThread project
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +39 -0
- README.md +60 -0
- check_rows.js +18 -0
- client/.gitignore +35 -0
- client/README.md +18 -0
- client/client_live.txt +0 -0
- client/eslint.config.js +29 -0
- client/lint.txt +0 -0
- client/next.config.mjs +6 -0
- client/package-lock.json +0 -0
- client/package.json +28 -0
- client/public/favicon.svg +1 -0
- client/public/vite.svg +1 -0
- client/src/api/client.js +44 -0
- client/src/app/About.css +196 -0
- client/src/app/History.css +160 -0
- client/src/app/Home.css +279 -0
- client/src/app/Miner.css +166 -0
- client/src/app/NotFound.css +54 -0
- client/src/app/about/page.jsx +110 -0
- client/src/app/globals.css +139 -0
- client/src/app/history/page.jsx +100 -0
- client/src/app/layout.jsx +21 -0
- client/src/app/not-found.jsx +23 -0
- client/src/app/page.jsx +342 -0
- client/src/app/terms/Terms.css +71 -0
- client/src/app/terms/page.jsx +60 -0
- client/src/assets/react.svg +1 -0
- client/src/components/ClarificationPrompt.css +111 -0
- client/src/components/ClarificationPrompt.jsx +52 -0
- client/src/components/ErrorBoundary.css +70 -0
- client/src/components/ErrorBoundary.jsx +48 -0
- client/src/components/FilterPanel.css +182 -0
- client/src/components/FilterPanel.jsx +120 -0
- client/src/components/Header.css +199 -0
- client/src/components/Header.jsx +56 -0
- client/src/components/Providers.jsx +11 -0
- client/src/components/ResultCard.css +158 -0
- client/src/components/ResultCard.jsx +53 -0
- client/src/components/ResultModal.css +179 -0
- client/src/components/ResultModal.jsx +98 -0
- client/src/components/SafetyBanner.css +29 -0
- client/src/components/SafetyBanner.jsx +20 -0
- client/src/components/SearchBar.css +277 -0
- client/src/components/SearchBar.jsx +192 -0
- client/src/components/SkeletonCard.css +64 -0
- client/src/components/SkeletonCard.jsx +21 -0
- client/src/contexts/ToastContext.css +70 -0
- client/src/contexts/ToastContext.jsx +54 -0
- client/src/hooks/useGeolocation.js +44 -0
.gitignore
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dependencies
|
| 2 |
+
node_modules/
|
| 3 |
+
.next/
|
| 4 |
+
dist/
|
| 5 |
+
dist-ssr/
|
| 6 |
+
build/
|
| 7 |
+
|
| 8 |
+
# Environment
|
| 9 |
+
.env
|
| 10 |
+
.env.local
|
| 11 |
+
.env.development.local
|
| 12 |
+
.env.test.local
|
| 13 |
+
.env.production.local
|
| 14 |
+
|
| 15 |
+
# Databases and Scraping
|
| 16 |
+
*.sqlite
|
| 17 |
+
locations.sqlite
|
| 18 |
+
*.db
|
| 19 |
+
scraper_log.txt
|
| 20 |
+
server_run.log
|
| 21 |
+
build_error.log
|
| 22 |
+
|
| 23 |
+
# Logs and Debugging
|
| 24 |
+
logs/
|
| 25 |
+
*.log
|
| 26 |
+
npm-debug.log*
|
| 27 |
+
yarn-debug.log*
|
| 28 |
+
yarn-error.log*
|
| 29 |
+
.pnpm-debug.log*
|
| 30 |
+
|
| 31 |
+
# IDEs
|
| 32 |
+
.vscode/
|
| 33 |
+
.idea/
|
| 34 |
+
*.swp
|
| 35 |
+
*.swo
|
| 36 |
+
|
| 37 |
+
# OS
|
| 38 |
+
.DS_Store
|
| 39 |
+
Thumbs.db
|
README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# RedThread
|
| 2 |
+
|
| 3 |
+
**AI-assisted location-based recommendation platform** that combines intelligent intent parsing, safety validation, and structured data extraction.
|
| 4 |
+
|
| 5 |
+
## Architecture
|
| 6 |
+
|
| 7 |
+
```
|
| 8 |
+
ultimate_spider/
|
| 9 |
+
├── client/ # React + Vite frontend
|
| 10 |
+
│ └── src/
|
| 11 |
+
│ ├── api/ # Backend communication layer
|
| 12 |
+
│ ├── components/ # Reusable UI components
|
| 13 |
+
│ ├── pages/ # Page-level compositions
|
| 14 |
+
│ └── styles/ # Design system
|
| 15 |
+
└── server/ # Node.js + Express backend
|
| 16 |
+
└── src/
|
| 17 |
+
├── config/ # Environment-driven configuration
|
| 18 |
+
├── controllers/ # Request orchestration (thin)
|
| 19 |
+
├── middleware/ # Rate limiting, safety guard, error handler
|
| 20 |
+
├── routes/ # HTTP route definitions
|
| 21 |
+
├── services/ # Business logic (AI, safety, scraper)
|
| 22 |
+
├── utils/ # Logger, custom errors
|
| 23 |
+
└── validators/ # Request body validation
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
## Quick Start
|
| 27 |
+
|
| 28 |
+
```bash
|
| 29 |
+
# Backend
|
| 30 |
+
cd server
|
| 31 |
+
npm install
|
| 32 |
+
cp .env.example .env # Add your GROQ_API_KEY
|
| 33 |
+
npm run dev
|
| 34 |
+
|
| 35 |
+
# Frontend (new terminal)
|
| 36 |
+
cd client
|
| 37 |
+
npm install
|
| 38 |
+
npm run dev
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
- **Frontend**: http://localhost:5173
|
| 42 |
+
- **Backend**: http://localhost:3001
|
| 43 |
+
- **Health**: http://localhost:3001/api/health
|
| 44 |
+
|
| 45 |
+
## Tech Stack
|
| 46 |
+
|
| 47 |
+
| Layer | Tech |
|
| 48 |
+
|-------|------|
|
| 49 |
+
| Frontend | React 19, Vite |
|
| 50 |
+
| Backend | Express 4, Node.js |
|
| 51 |
+
| AI | Groq API (Llama 3.3 70B) |
|
| 52 |
+
| Security | Helmet, CORS, Rate Limiting, Safety Middleware |
|
| 53 |
+
|
| 54 |
+
## Environment Variables
|
| 55 |
+
|
| 56 |
+
| Variable | Description |
|
| 57 |
+
|----------|-------------|
|
| 58 |
+
| `PORT` | Server port (default: 3001) |
|
| 59 |
+
| `NODE_ENV` | Environment (development/production) |
|
| 60 |
+
| `GROQ_API_KEY` | Groq API key for AI intent parsing |
|
check_rows.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { getDb } = require('./server/src/db/database');
|
| 2 |
+
|
| 3 |
+
async function checkRows() {
|
| 4 |
+
try {
|
| 5 |
+
const db = await getDb();
|
| 6 |
+
const count = await db.get('SELECT COUNT(*) as count FROM places');
|
| 7 |
+
console.log(`TOTAL ROWS: ${count.count}`);
|
| 8 |
+
|
| 9 |
+
const first = await db.get('SELECT * FROM places LIMIT 1');
|
| 10 |
+
console.log('FIRST ROW:', JSON.stringify(first, null, 2));
|
| 11 |
+
} catch (err) {
|
| 12 |
+
console.error('Debug failed:', err.message);
|
| 13 |
+
} finally {
|
| 14 |
+
process.exit(0);
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
checkRows();
|
client/.gitignore
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.js
|
| 7 |
+
|
| 8 |
+
# testing
|
| 9 |
+
/coverage
|
| 10 |
+
|
| 11 |
+
# next.js
|
| 12 |
+
/.next/
|
| 13 |
+
/out/
|
| 14 |
+
|
| 15 |
+
# production
|
| 16 |
+
/build
|
| 17 |
+
|
| 18 |
+
# misc
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.pem
|
| 21 |
+
|
| 22 |
+
# debug
|
| 23 |
+
npm-debug.log*
|
| 24 |
+
yarn-debug.log*
|
| 25 |
+
yarn-error.log*
|
| 26 |
+
|
| 27 |
+
# local env files
|
| 28 |
+
.env*.local
|
| 29 |
+
|
| 30 |
+
# vercel
|
| 31 |
+
.vercel
|
| 32 |
+
|
| 33 |
+
# typescript
|
| 34 |
+
*.tsbuildinfo
|
| 35 |
+
next-env.d.ts
|
client/README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# React + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
| 9 |
+
|
| 10 |
+
## React Compiler
|
| 11 |
+
|
| 12 |
+
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
|
| 13 |
+
|
| 14 |
+
Note: This will impact Vite dev & build performances.
|
| 15 |
+
|
| 16 |
+
## Expanding the ESLint configuration
|
| 17 |
+
|
| 18 |
+
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
client/client_live.txt
ADDED
|
Binary file (750 Bytes). View file
|
|
|
client/eslint.config.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
| 6 |
+
|
| 7 |
+
export default defineConfig([
|
| 8 |
+
globalIgnores(['dist']),
|
| 9 |
+
{
|
| 10 |
+
files: ['**/*.{js,jsx}'],
|
| 11 |
+
extends: [
|
| 12 |
+
js.configs.recommended,
|
| 13 |
+
reactHooks.configs.flat.recommended,
|
| 14 |
+
reactRefresh.configs.vite,
|
| 15 |
+
],
|
| 16 |
+
languageOptions: {
|
| 17 |
+
ecmaVersion: 2020,
|
| 18 |
+
globals: globals.browser,
|
| 19 |
+
parserOptions: {
|
| 20 |
+
ecmaVersion: 'latest',
|
| 21 |
+
ecmaFeatures: { jsx: true },
|
| 22 |
+
sourceType: 'module',
|
| 23 |
+
},
|
| 24 |
+
},
|
| 25 |
+
rules: {
|
| 26 |
+
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
| 27 |
+
},
|
| 28 |
+
},
|
| 29 |
+
])
|
client/lint.txt
ADDED
|
Binary file (1.23 kB). View file
|
|
|
client/next.config.mjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('next').NextConfig} */
|
| 2 |
+
const nextConfig = {
|
| 3 |
+
reactStrictMode: true,
|
| 4 |
+
};
|
| 5 |
+
|
| 6 |
+
export default nextConfig;
|
client/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
client/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "client",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "next dev -p 5173",
|
| 8 |
+
"build": "next build",
|
| 9 |
+
"start": "next start",
|
| 10 |
+
"lint": "next lint"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"next": "^15.1.0",
|
| 14 |
+
"react": "^19.2.0",
|
| 15 |
+
"react-dom": "^19.2.0"
|
| 16 |
+
},
|
| 17 |
+
"devDependencies": {
|
| 18 |
+
"@eslint/js": "^9.39.1",
|
| 19 |
+
"@types/react": "^19.2.7",
|
| 20 |
+
"@types/react-dom": "^19.2.3",
|
| 21 |
+
"babel-plugin-react-compiler": "^1.0.0",
|
| 22 |
+
"eslint": "^9.39.1",
|
| 23 |
+
"eslint-config-next": "^15.1.0",
|
| 24 |
+
"eslint-plugin-react-hooks": "^7.0.1",
|
| 25 |
+
"eslint-plugin-react-refresh": "^0.4.24",
|
| 26 |
+
"globals": "^16.5.0"
|
| 27 |
+
}
|
| 28 |
+
}
|
client/public/favicon.svg
ADDED
|
|
client/public/vite.svg
ADDED
|
|
client/src/api/client.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
| 2 |
+
|
| 3 |
+
async function request(endpoint, options = {}) {
|
| 4 |
+
const url = `${BASE_URL}${endpoint}`;
|
| 5 |
+
const config = {
|
| 6 |
+
headers: { 'Content-Type': 'application/json' },
|
| 7 |
+
...options,
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
const response = await fetch(url, config);
|
| 11 |
+
const data = await response.json();
|
| 12 |
+
|
| 13 |
+
if (!response.ok) {
|
| 14 |
+
const error = new Error(data.error || 'Request failed');
|
| 15 |
+
error.status = response.status;
|
| 16 |
+
error.data = data;
|
| 17 |
+
throw error;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
return data;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export function getHealth() {
|
| 24 |
+
return request('/health');
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export function search(query, location, filters, userLocation, clarificationContext) {
|
| 28 |
+
return request('/search', {
|
| 29 |
+
method: 'POST',
|
| 30 |
+
body: JSON.stringify({ query, location, filters, userLocation, clarificationContext }),
|
| 31 |
+
});
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export function getCategories() {
|
| 35 |
+
return request('/categories');
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export function getSuggestions(q) {
|
| 39 |
+
return request(`/suggestions?q=${encodeURIComponent(q)}`);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export function getPlaceById(id) {
|
| 43 |
+
return request(`/suggestions/${id}`);
|
| 44 |
+
}
|
client/src/app/About.css
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.about {
|
| 2 |
+
max-width: 900px;
|
| 3 |
+
margin: 0 auto;
|
| 4 |
+
padding: 2rem 1.5rem 4rem;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
.about-hero {
|
| 8 |
+
text-align: center;
|
| 9 |
+
padding: 2rem 0 2.5rem;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
.about-title {
|
| 13 |
+
font-size: 2rem;
|
| 14 |
+
font-weight: 800;
|
| 15 |
+
letter-spacing: -0.03em;
|
| 16 |
+
margin-bottom: 1rem;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
.accent-text {
|
| 20 |
+
background: var(--accent-gradient);
|
| 21 |
+
-webkit-background-clip: text;
|
| 22 |
+
-webkit-text-fill-color: transparent;
|
| 23 |
+
background-clip: text;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.about-lead {
|
| 27 |
+
font-size: 1rem;
|
| 28 |
+
color: var(--text-secondary);
|
| 29 |
+
max-width: 600px;
|
| 30 |
+
margin: 0 auto;
|
| 31 |
+
line-height: 1.7;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.about-section {
|
| 35 |
+
margin-bottom: 2.5rem;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.about-section h3 {
|
| 39 |
+
font-size: 1.1rem;
|
| 40 |
+
font-weight: 700;
|
| 41 |
+
margin-bottom: 1rem;
|
| 42 |
+
padding-bottom: 0.5rem;
|
| 43 |
+
border-bottom: 1px solid var(--border-subtle);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
.about-pipeline {
|
| 47 |
+
display: flex;
|
| 48 |
+
align-items: center;
|
| 49 |
+
gap: 0.25rem;
|
| 50 |
+
flex-wrap: wrap;
|
| 51 |
+
justify-content: center;
|
| 52 |
+
padding: 1rem 0;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.pipeline-step {
|
| 56 |
+
display: flex;
|
| 57 |
+
align-items: center;
|
| 58 |
+
gap: 0.4rem;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.pipeline-num {
|
| 62 |
+
width: 24px;
|
| 63 |
+
height: 24px;
|
| 64 |
+
border-radius: 50%;
|
| 65 |
+
background: var(--accent-gradient);
|
| 66 |
+
color: white;
|
| 67 |
+
font-size: 0.7rem;
|
| 68 |
+
font-weight: 700;
|
| 69 |
+
display: flex;
|
| 70 |
+
align-items: center;
|
| 71 |
+
justify-content: center;
|
| 72 |
+
flex-shrink: 0;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.pipeline-label {
|
| 76 |
+
font-size: 0.82rem;
|
| 77 |
+
font-weight: 500;
|
| 78 |
+
color: var(--text-primary);
|
| 79 |
+
white-space: nowrap;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.pipeline-arrow {
|
| 83 |
+
color: var(--text-muted);
|
| 84 |
+
margin: 0 0.25rem;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.about-grid {
|
| 88 |
+
display: grid;
|
| 89 |
+
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
| 90 |
+
gap: 0.75rem;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.about-card {
|
| 94 |
+
background: var(--bg-card);
|
| 95 |
+
border: 1px solid var(--border-subtle);
|
| 96 |
+
border-radius: var(--radius-md);
|
| 97 |
+
padding: 1rem 1.25rem;
|
| 98 |
+
transition: all var(--transition-normal);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.about-card:hover {
|
| 102 |
+
background: var(--bg-card-hover);
|
| 103 |
+
border-color: rgba(238, 105, 131, 0.15);
|
| 104 |
+
transform: translateY(-2px);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.about-card-icon {
|
| 108 |
+
font-size: 1.5rem;
|
| 109 |
+
display: block;
|
| 110 |
+
margin-bottom: 0.4rem;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.about-card h4 {
|
| 114 |
+
font-size: 0.9rem;
|
| 115 |
+
font-weight: 600;
|
| 116 |
+
margin-bottom: 0.25rem;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.about-card p {
|
| 120 |
+
font-size: 0.78rem;
|
| 121 |
+
color: var(--text-secondary);
|
| 122 |
+
line-height: 1.4;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.about-table-wrap {
|
| 126 |
+
overflow-x: auto;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.about-table {
|
| 130 |
+
width: 100%;
|
| 131 |
+
border-collapse: collapse;
|
| 132 |
+
font-size: 0.82rem;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.about-table th {
|
| 136 |
+
text-align: left;
|
| 137 |
+
padding: 0.6rem 0.75rem;
|
| 138 |
+
background: var(--bg-card);
|
| 139 |
+
color: var(--text-muted);
|
| 140 |
+
font-weight: 600;
|
| 141 |
+
font-size: 0.7rem;
|
| 142 |
+
text-transform: uppercase;
|
| 143 |
+
letter-spacing: 0.08em;
|
| 144 |
+
border-bottom: 1px solid var(--border-subtle);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.about-table td {
|
| 148 |
+
padding: 0.6rem 0.75rem;
|
| 149 |
+
border-bottom: 1px solid var(--border-subtle);
|
| 150 |
+
color: var(--text-secondary);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.about-table-layer {
|
| 154 |
+
font-weight: 600;
|
| 155 |
+
color: var(--accent-secondary) !important;
|
| 156 |
+
white-space: nowrap;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.about-table-interview {
|
| 160 |
+
font-style: italic;
|
| 161 |
+
font-size: 0.78rem;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.about-safety {
|
| 165 |
+
display: flex;
|
| 166 |
+
flex-direction: column;
|
| 167 |
+
gap: 0.75rem;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.about-safety-item {
|
| 171 |
+
display: flex;
|
| 172 |
+
align-items: flex-start;
|
| 173 |
+
gap: 0.75rem;
|
| 174 |
+
padding: 0.75rem 1rem;
|
| 175 |
+
background: var(--bg-card);
|
| 176 |
+
border: 1px solid var(--border-subtle);
|
| 177 |
+
border-radius: var(--radius-md);
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
.about-safety-icon {
|
| 181 |
+
font-size: 1.25rem;
|
| 182 |
+
flex-shrink: 0;
|
| 183 |
+
margin-top: 0.1rem;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.about-safety-item h4 {
|
| 187 |
+
font-size: 0.85rem;
|
| 188 |
+
font-weight: 600;
|
| 189 |
+
margin-bottom: 0.15rem;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.about-safety-item p {
|
| 193 |
+
font-size: 0.78rem;
|
| 194 |
+
color: var(--text-secondary);
|
| 195 |
+
line-height: 1.4;
|
| 196 |
+
}
|
client/src/app/History.css
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.history-page {
|
| 2 |
+
max-width: 800px;
|
| 3 |
+
margin: 0 auto;
|
| 4 |
+
padding: 2rem 1.5rem 4rem;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
.history-header {
|
| 8 |
+
display: flex;
|
| 9 |
+
align-items: flex-start;
|
| 10 |
+
justify-content: space-between;
|
| 11 |
+
margin-bottom: 1.5rem;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
.history-header h2 {
|
| 15 |
+
font-size: 1.5rem;
|
| 16 |
+
font-weight: 700;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
.history-subtitle {
|
| 20 |
+
font-size: 0.8rem;
|
| 21 |
+
color: var(--text-muted);
|
| 22 |
+
margin-top: 0.2rem;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
.history-clear-btn {
|
| 26 |
+
display: flex;
|
| 27 |
+
align-items: center;
|
| 28 |
+
gap: 0.35rem;
|
| 29 |
+
padding: 0.4rem 0.75rem;
|
| 30 |
+
background: rgba(214, 48, 49, 0.1);
|
| 31 |
+
border: 1px solid rgba(214, 48, 49, 0.2);
|
| 32 |
+
border-radius: var(--radius-sm);
|
| 33 |
+
color: #e17055;
|
| 34 |
+
font-size: 0.78rem;
|
| 35 |
+
font-weight: 500;
|
| 36 |
+
transition: all var(--transition-fast);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.history-clear-btn:hover {
|
| 40 |
+
background: rgba(214, 48, 49, 0.2);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.history-empty {
|
| 44 |
+
text-align: center;
|
| 45 |
+
padding: 4rem 1rem;
|
| 46 |
+
color: var(--text-muted);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.history-empty svg {
|
| 50 |
+
margin-bottom: 1rem;
|
| 51 |
+
opacity: 0.3;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.history-empty p {
|
| 55 |
+
font-size: 1rem;
|
| 56 |
+
font-weight: 500;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.history-empty-sub {
|
| 60 |
+
font-size: 0.82rem !important;
|
| 61 |
+
font-weight: 400 !important;
|
| 62 |
+
color: var(--text-muted);
|
| 63 |
+
margin-top: 0.25rem;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
.history-list {
|
| 67 |
+
display: flex;
|
| 68 |
+
flex-direction: column;
|
| 69 |
+
gap: 0.5rem;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.history-item {
|
| 73 |
+
display: flex;
|
| 74 |
+
align-items: center;
|
| 75 |
+
justify-content: space-between;
|
| 76 |
+
gap: 1rem;
|
| 77 |
+
padding: 0.75rem 1rem;
|
| 78 |
+
background: var(--bg-card);
|
| 79 |
+
border: 1px solid var(--border-subtle);
|
| 80 |
+
border-radius: var(--radius-md);
|
| 81 |
+
transition: all var(--transition-fast);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.history-item:hover {
|
| 85 |
+
background: var(--bg-card-hover);
|
| 86 |
+
border-color: rgba(238, 105, 131, 0.15);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.history-item-main {
|
| 90 |
+
flex: 1;
|
| 91 |
+
min-width: 0;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.history-item-query {
|
| 95 |
+
font-size: 0.9rem;
|
| 96 |
+
font-weight: 500;
|
| 97 |
+
color: var(--text-primary);
|
| 98 |
+
margin-bottom: 0.3rem;
|
| 99 |
+
white-space: nowrap;
|
| 100 |
+
overflow: hidden;
|
| 101 |
+
text-overflow: ellipsis;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.history-item-meta {
|
| 105 |
+
display: flex;
|
| 106 |
+
align-items: center;
|
| 107 |
+
gap: 0.4rem;
|
| 108 |
+
flex-wrap: wrap;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
.history-chip {
|
| 112 |
+
font-size: 0.65rem;
|
| 113 |
+
font-weight: 600;
|
| 114 |
+
text-transform: uppercase;
|
| 115 |
+
letter-spacing: 0.05em;
|
| 116 |
+
padding: 0.1rem 0.4rem;
|
| 117 |
+
border-radius: 4px;
|
| 118 |
+
background: rgba(238, 105, 131, 0.12);
|
| 119 |
+
color: var(--accent-secondary);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.history-item-results,
|
| 123 |
+
.history-item-time {
|
| 124 |
+
font-size: 0.7rem;
|
| 125 |
+
color: var(--text-muted);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.history-item-time::before {
|
| 129 |
+
content: '·';
|
| 130 |
+
margin-right: 0.4rem;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.history-item-actions {
|
| 134 |
+
display: flex;
|
| 135 |
+
gap: 0.25rem;
|
| 136 |
+
flex-shrink: 0;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.history-rerun-btn,
|
| 140 |
+
.history-remove-btn {
|
| 141 |
+
width: 30px;
|
| 142 |
+
height: 30px;
|
| 143 |
+
display: flex;
|
| 144 |
+
align-items: center;
|
| 145 |
+
justify-content: center;
|
| 146 |
+
border-radius: var(--radius-sm);
|
| 147 |
+
background: transparent;
|
| 148 |
+
color: var(--text-muted);
|
| 149 |
+
transition: all var(--transition-fast);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.history-rerun-btn:hover {
|
| 153 |
+
background: rgba(238, 105, 131, 0.15);
|
| 154 |
+
color: var(--accent-secondary);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.history-remove-btn:hover {
|
| 158 |
+
background: rgba(214, 48, 49, 0.15);
|
| 159 |
+
color: #e17055;
|
| 160 |
+
}
|
client/src/app/Home.css
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.home {
|
| 2 |
+
flex: 1;
|
| 3 |
+
max-width: 1200px;
|
| 4 |
+
width: 100%;
|
| 5 |
+
margin: 0 auto;
|
| 6 |
+
padding: 0 1.5rem 3rem;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
.home-hero {
|
| 10 |
+
position: relative;
|
| 11 |
+
text-align: center;
|
| 12 |
+
padding: 3.5rem 0 2rem;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
.home-hero-glow {
|
| 16 |
+
position: absolute;
|
| 17 |
+
top: -40px;
|
| 18 |
+
left: 50%;
|
| 19 |
+
transform: translateX(-50%);
|
| 20 |
+
width: 400px;
|
| 21 |
+
height: 400px;
|
| 22 |
+
background: radial-gradient(circle, rgba(238, 105, 131, 0.12) 0%, transparent 70%);
|
| 23 |
+
pointer-events: none;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.home-headline {
|
| 27 |
+
font-size: 2.5rem;
|
| 28 |
+
font-weight: 800;
|
| 29 |
+
letter-spacing: -0.03em;
|
| 30 |
+
line-height: 1.15;
|
| 31 |
+
margin-bottom: 1rem;
|
| 32 |
+
color: var(--text-primary);
|
| 33 |
+
text-shadow: none;
|
| 34 |
+
filter: none;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
.home-headline-accent {
|
| 38 |
+
background: linear-gradient(90deg,
|
| 39 |
+
#FFC4C4 0%,
|
| 40 |
+
#fd79a8 25%,
|
| 41 |
+
#FCF5EE 50%,
|
| 42 |
+
#fd79a8 75%,
|
| 43 |
+
#FFC4C4 100%);
|
| 44 |
+
background-size: 200% auto;
|
| 45 |
+
-webkit-background-clip: text;
|
| 46 |
+
-webkit-text-fill-color: transparent;
|
| 47 |
+
background-clip: text;
|
| 48 |
+
animation: flowing-light 3s linear infinite;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
@keyframes flowing-light {
|
| 52 |
+
to {
|
| 53 |
+
background-position: 200% center;
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.home-subline {
|
| 58 |
+
font-size: 1rem;
|
| 59 |
+
color: var(--text-secondary);
|
| 60 |
+
max-width: 540px;
|
| 61 |
+
margin: 0 auto 2rem;
|
| 62 |
+
line-height: 1.6;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.home-error {
|
| 66 |
+
display: flex;
|
| 67 |
+
align-items: center;
|
| 68 |
+
gap: 0.5rem;
|
| 69 |
+
max-width: 720px;
|
| 70 |
+
margin: 1.5rem auto;
|
| 71 |
+
padding: 0.75rem 1rem;
|
| 72 |
+
background: rgba(214, 48, 49, 0.1);
|
| 73 |
+
border: 1px solid rgba(214, 48, 49, 0.2);
|
| 74 |
+
border-radius: var(--radius-md);
|
| 75 |
+
color: #e17055;
|
| 76 |
+
font-size: 0.85rem;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.error-action-btn {
|
| 80 |
+
background: var(--accent-gradient);
|
| 81 |
+
border: none;
|
| 82 |
+
padding: 0.25rem 0.75rem;
|
| 83 |
+
border-radius: 4px;
|
| 84 |
+
color: white;
|
| 85 |
+
font-size: 0.75rem;
|
| 86 |
+
font-weight: 600;
|
| 87 |
+
cursor: pointer;
|
| 88 |
+
transition: transform 0.2s ease;
|
| 89 |
+
margin-left: 0.5rem;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.error-action-btn:hover {
|
| 93 |
+
transform: scale(1.05);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.home-intent {
|
| 97 |
+
max-width: 720px;
|
| 98 |
+
margin: 1.5rem auto;
|
| 99 |
+
padding: 1rem 1.25rem;
|
| 100 |
+
background: var(--bg-card);
|
| 101 |
+
border: 1px solid var(--border-subtle);
|
| 102 |
+
border-radius: var(--radius-md);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.home-intent-title {
|
| 106 |
+
font-size: 0.7rem;
|
| 107 |
+
text-transform: uppercase;
|
| 108 |
+
letter-spacing: 0.1em;
|
| 109 |
+
color: var(--text-muted);
|
| 110 |
+
margin-bottom: 0.5rem;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.home-intent-chips {
|
| 114 |
+
display: flex;
|
| 115 |
+
flex-wrap: wrap;
|
| 116 |
+
gap: 0.5rem;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.intent-chip {
|
| 120 |
+
font-size: 0.78rem;
|
| 121 |
+
padding: 0.25rem 0.6rem;
|
| 122 |
+
border-radius: 6px;
|
| 123 |
+
background: rgba(238, 105, 131, 0.1);
|
| 124 |
+
color: var(--text-secondary);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.intent-chip strong {
|
| 128 |
+
color: var(--accent-secondary);
|
| 129 |
+
font-weight: 600;
|
| 130 |
+
margin-right: 0.2rem;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.intent-chip.reasoning {
|
| 134 |
+
width: 100%;
|
| 135 |
+
margin-bottom: 0.25rem;
|
| 136 |
+
background: rgba(255, 255, 255, 0.03);
|
| 137 |
+
border: 1px dashed rgba(238, 105, 131, 0.3);
|
| 138 |
+
font-style: italic;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
.home-results-layout {
|
| 142 |
+
display: grid;
|
| 143 |
+
grid-template-columns: 280px 1fr;
|
| 144 |
+
gap: 1.5rem;
|
| 145 |
+
margin-top: 1.5rem;
|
| 146 |
+
align-items: start;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.home-results {
|
| 150 |
+
min-width: 0;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.home-results-header {
|
| 154 |
+
display: flex;
|
| 155 |
+
align-items: baseline;
|
| 156 |
+
justify-content: space-between;
|
| 157 |
+
margin-bottom: 1rem;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.home-results-header h3 {
|
| 161 |
+
font-size: 1.1rem;
|
| 162 |
+
font-weight: 600;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
.home-results-meta {
|
| 166 |
+
font-size: 0.75rem;
|
| 167 |
+
color: var(--text-muted);
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.home-results-grid {
|
| 171 |
+
display: grid;
|
| 172 |
+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
| 173 |
+
gap: 1rem;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
.home-empty {
|
| 177 |
+
text-align: center;
|
| 178 |
+
padding: 3rem 1rem;
|
| 179 |
+
color: var(--text-muted);
|
| 180 |
+
font-size: 0.9rem;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.home-recent-searches {
|
| 184 |
+
margin-top: 1.5rem;
|
| 185 |
+
display: flex;
|
| 186 |
+
flex-direction: column;
|
| 187 |
+
align-items: center;
|
| 188 |
+
gap: 0.75rem;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.recent-label {
|
| 192 |
+
font-size: 0.75rem;
|
| 193 |
+
color: var(--text-muted);
|
| 194 |
+
font-weight: 500;
|
| 195 |
+
text-transform: uppercase;
|
| 196 |
+
letter-spacing: 0.05em;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.recent-links {
|
| 200 |
+
display: flex;
|
| 201 |
+
flex-wrap: wrap;
|
| 202 |
+
justify-content: center;
|
| 203 |
+
gap: 0.5rem;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.recent-link-btn {
|
| 207 |
+
display: flex;
|
| 208 |
+
align-items: center;
|
| 209 |
+
gap: 0.4rem;
|
| 210 |
+
padding: 0.4rem 0.8rem;
|
| 211 |
+
background: rgba(255, 255, 255, 0.03);
|
| 212 |
+
border: 1px solid var(--border-subtle);
|
| 213 |
+
border-radius: 20px;
|
| 214 |
+
color: var(--text-secondary);
|
| 215 |
+
font-size: 0.85rem;
|
| 216 |
+
transition: all var(--transition-fast);
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.recent-link-btn:hover {
|
| 220 |
+
background: rgba(238, 105, 131, 0.1);
|
| 221 |
+
border-color: rgba(238, 105, 131, 0.3);
|
| 222 |
+
color: var(--accent-secondary);
|
| 223 |
+
transform: translateY(-1px);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
.recent-link-btn svg {
|
| 227 |
+
color: var(--text-muted);
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
.home-scope-guidance {
|
| 231 |
+
display: flex;
|
| 232 |
+
gap: 1.25rem;
|
| 233 |
+
max-width: 720px;
|
| 234 |
+
margin: 1.5rem auto;
|
| 235 |
+
padding: 1.5rem;
|
| 236 |
+
background: rgba(238, 105, 131, 0.08);
|
| 237 |
+
border: 1px solid rgba(238, 105, 131, 0.15);
|
| 238 |
+
border-radius: var(--radius-md);
|
| 239 |
+
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
.scope-icon {
|
| 243 |
+
font-size: 1.5rem;
|
| 244 |
+
background: rgba(238, 105, 131, 0.12);
|
| 245 |
+
width: 45px;
|
| 246 |
+
height: 45px;
|
| 247 |
+
display: flex;
|
| 248 |
+
align-items: center;
|
| 249 |
+
justify-content: center;
|
| 250 |
+
border-radius: 12px;
|
| 251 |
+
flex-shrink: 0;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
.scope-content h4 {
|
| 255 |
+
font-size: 0.95rem;
|
| 256 |
+
font-weight: 700;
|
| 257 |
+
color: var(--accent-secondary);
|
| 258 |
+
margin-bottom: 0.4rem;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
.scope-content p {
|
| 262 |
+
font-size: 0.88rem;
|
| 263 |
+
line-height: 1.55;
|
| 264 |
+
color: var(--text-secondary);
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
@media (max-width: 768px) {
|
| 268 |
+
.home-headline {
|
| 269 |
+
font-size: 1.75rem;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
.home-results-layout {
|
| 273 |
+
grid-template-columns: 1fr;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.home-results-grid {
|
| 277 |
+
grid-template-columns: 1fr;
|
| 278 |
+
}
|
| 279 |
+
}
|
client/src/app/Miner.css
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.miner-container {
|
| 2 |
+
padding: 3rem 5%;
|
| 3 |
+
max-width: 1200px;
|
| 4 |
+
margin: 0 auto;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
.miner-header {
|
| 8 |
+
text-align: center;
|
| 9 |
+
margin-bottom: 3rem;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
.miner-header h2 {
|
| 13 |
+
font-size: 2.5rem;
|
| 14 |
+
font-weight: 700;
|
| 15 |
+
margin-bottom: 0.5rem;
|
| 16 |
+
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
|
| 17 |
+
-webkit-background-clip: text;
|
| 18 |
+
-webkit-text-fill-color: transparent;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
.miner-header p {
|
| 22 |
+
color: var(--text-secondary);
|
| 23 |
+
font-size: 1.1rem;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.miner-content {
|
| 27 |
+
display: grid;
|
| 28 |
+
grid-template-columns: 2fr 1fr;
|
| 29 |
+
gap: 2rem;
|
| 30 |
+
align-items: start;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.miner-form {
|
| 34 |
+
background: var(--bg-card);
|
| 35 |
+
padding: 2.5rem;
|
| 36 |
+
border-radius: 16px;
|
| 37 |
+
border: 1px solid var(--border);
|
| 38 |
+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.form-group {
|
| 42 |
+
margin-bottom: 1.5rem;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
.form-group label {
|
| 46 |
+
display: block;
|
| 47 |
+
margin-bottom: 0.5rem;
|
| 48 |
+
color: var(--text-secondary);
|
| 49 |
+
font-weight: 500;
|
| 50 |
+
font-size: 0.95rem;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.form-group input,
|
| 54 |
+
.form-group select {
|
| 55 |
+
width: 100%;
|
| 56 |
+
padding: 1rem;
|
| 57 |
+
background: var(--bg-main);
|
| 58 |
+
border: 1px solid var(--border);
|
| 59 |
+
border-radius: 8px;
|
| 60 |
+
color: var(--text-main);
|
| 61 |
+
font-family: 'Inter', sans-serif;
|
| 62 |
+
font-size: 1rem;
|
| 63 |
+
transition: all 0.2s ease;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
.form-group input:focus,
|
| 67 |
+
.form-group select:focus {
|
| 68 |
+
outline: none;
|
| 69 |
+
border-color: var(--primary);
|
| 70 |
+
box-shadow: 0 0 0 2px rgba(108, 92, 237, 0.2);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.form-group input:disabled,
|
| 74 |
+
.form-group select:disabled {
|
| 75 |
+
opacity: 0.5;
|
| 76 |
+
cursor: not-allowed;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.form-row {
|
| 80 |
+
display: grid;
|
| 81 |
+
grid-template-columns: 1fr 1fr;
|
| 82 |
+
gap: 1.5rem;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.miner-btn {
|
| 86 |
+
width: 100%;
|
| 87 |
+
padding: 1rem;
|
| 88 |
+
font-size: 1.1rem;
|
| 89 |
+
margin-top: 1rem;
|
| 90 |
+
display: flex;
|
| 91 |
+
justify-content: center;
|
| 92 |
+
align-items: center;
|
| 93 |
+
gap: 10px;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.miner-btn.mining {
|
| 97 |
+
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
|
| 98 |
+
cursor: wait;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.mining-status-text {
|
| 102 |
+
margin-top: 1.5rem;
|
| 103 |
+
padding: 1rem;
|
| 104 |
+
background: rgba(253, 203, 110, 0.1);
|
| 105 |
+
border: 1px solid rgba(253, 203, 110, 0.3);
|
| 106 |
+
border-radius: 8px;
|
| 107 |
+
color: #fdcb6e;
|
| 108 |
+
font-size: 0.9rem;
|
| 109 |
+
text-align: center;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.spinner {
|
| 113 |
+
width: 20px;
|
| 114 |
+
height: 20px;
|
| 115 |
+
border: 3px solid rgba(255, 255, 255, 0.3);
|
| 116 |
+
border-radius: 50%;
|
| 117 |
+
border-top-color: white;
|
| 118 |
+
animation: spin 1s ease-in-out infinite;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
@keyframes spin {
|
| 122 |
+
to {
|
| 123 |
+
transform: rotate(360deg);
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.miner-info {
|
| 128 |
+
background: var(--bg-card);
|
| 129 |
+
padding: 2rem;
|
| 130 |
+
border-radius: 16px;
|
| 131 |
+
border: 1px solid var(--border);
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.miner-info h3 {
|
| 135 |
+
font-size: 1.2rem;
|
| 136 |
+
margin-bottom: 1.5rem;
|
| 137 |
+
color: var(--text-main);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.miner-info ul {
|
| 141 |
+
list-style: none;
|
| 142 |
+
padding: 0;
|
| 143 |
+
margin: 0;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.miner-info li {
|
| 147 |
+
margin-bottom: 1.5rem;
|
| 148 |
+
color: var(--text-secondary);
|
| 149 |
+
line-height: 1.6;
|
| 150 |
+
font-size: 0.95rem;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.miner-info li strong {
|
| 154 |
+
color: var(--text-main);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
@media (max-width: 768px) {
|
| 158 |
+
.miner-content {
|
| 159 |
+
grid-template-columns: 1fr;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.form-row {
|
| 163 |
+
grid-template-columns: 1fr;
|
| 164 |
+
gap: 0;
|
| 165 |
+
}
|
| 166 |
+
}
|
client/src/app/NotFound.css
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.notfound-container {
|
| 2 |
+
min-height: calc(100vh - 64px);
|
| 3 |
+
display: flex;
|
| 4 |
+
align-items: center;
|
| 5 |
+
justify-content: center;
|
| 6 |
+
padding: 2rem;
|
| 7 |
+
background: var(--bg-primary);
|
| 8 |
+
text-align: center;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
.notfound-content {
|
| 12 |
+
display: flex;
|
| 13 |
+
flex-direction: column;
|
| 14 |
+
align-items: center;
|
| 15 |
+
gap: 1rem;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
.notfound-title {
|
| 19 |
+
font-size: 6rem;
|
| 20 |
+
font-weight: 800;
|
| 21 |
+
line-height: 1;
|
| 22 |
+
background: var(--accent-gradient);
|
| 23 |
+
-webkit-background-clip: text;
|
| 24 |
+
-webkit-text-fill-color: transparent;
|
| 25 |
+
filter: drop-shadow(0 0 20px rgba(238, 105, 131, 0.3));
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
.notfound-subtitle {
|
| 29 |
+
font-size: 1.5rem;
|
| 30 |
+
color: var(--text-primary);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.notfound-text {
|
| 34 |
+
color: var(--text-secondary);
|
| 35 |
+
font-size: 1rem;
|
| 36 |
+
margin-bottom: 1rem;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.notfound-btn {
|
| 40 |
+
padding: 0.75rem 2rem;
|
| 41 |
+
background: var(--bg-secondary);
|
| 42 |
+
color: var(--text-primary);
|
| 43 |
+
text-decoration: none;
|
| 44 |
+
border: 1px solid var(--border-subtle);
|
| 45 |
+
border-radius: var(--radius-md);
|
| 46 |
+
font-weight: 600;
|
| 47 |
+
transition: all var(--transition-normal);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.notfound-btn:hover {
|
| 51 |
+
background: var(--bg-input);
|
| 52 |
+
border-color: var(--border-hover);
|
| 53 |
+
transform: translateY(-2px);
|
| 54 |
+
}
|
client/src/app/about/page.jsx
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import '../About.css';
|
| 4 |
+
|
| 5 |
+
const TECH_STACK = [
|
| 6 |
+
{ name: 'React 19', desc: 'Component-based UI with hooks', icon: '⚛️' },
|
| 7 |
+
{ name: 'Next.js 15', desc: 'App Router, Server Components, and optimized performance', icon: '🚀' },
|
| 8 |
+
{ name: 'Express', desc: 'Layered REST API with middleware', icon: '🛤️' },
|
| 9 |
+
{ name: 'Groq + Llama 3.3', desc: 'LLM intent parsing and review analysis', icon: '🧠' },
|
| 10 |
+
{ name: 'Helmet + CORS', desc: 'Security headers and cross-origin policy', icon: '🛡️' },
|
| 11 |
+
{ name: 'Rate Limiting', desc: 'Per-IP request throttling', icon: '⏱️' },
|
| 12 |
+
];
|
| 13 |
+
|
| 14 |
+
const ARCHITECTURE = [
|
| 15 |
+
{ layer: 'Routes', purpose: 'HTTP endpoint definitions', interview: 'Keeps routing declarative and separate from logic' },
|
| 16 |
+
{ layer: 'Middleware', purpose: 'Cross-cutting concerns (auth, safety, rate limit)', interview: 'Runs before controller — reject early, save resources' },
|
| 17 |
+
{ layer: 'Controllers', purpose: 'Thin orchestration layer', interview: 'Calls services in order, shapes response — no business logic here' },
|
| 18 |
+
{ layer: 'Services', purpose: 'Business logic (AI, scraping, safety)', interview: 'Independently testable, reusable across routes' },
|
| 19 |
+
{ layer: 'Validators', purpose: 'Request body schema validation', interview: 'Fail fast with clear messages before processing' },
|
| 20 |
+
];
|
| 21 |
+
|
| 22 |
+
export default function About() {
|
| 23 |
+
return (
|
| 24 |
+
<main className="about">
|
| 25 |
+
<section className="about-hero fade-in-up">
|
| 26 |
+
<h2 className="about-title">
|
| 27 |
+
What is <span className="accent-text">RedThread</span>?
|
| 28 |
+
</h2>
|
| 29 |
+
<p className="about-lead">
|
| 30 |
+
RedThread is an AI that thinks before it searches.
|
| 31 |
+
By interpreting your intent and adapting to context, it removes the noise
|
| 32 |
+
of traditional search to find results that actually match your needs.
|
| 33 |
+
</p>
|
| 34 |
+
</section>
|
| 35 |
+
|
| 36 |
+
<section className="about-section fade-in-up stagger-1">
|
| 37 |
+
<h3>How It Works</h3>
|
| 38 |
+
<div className="about-pipeline">
|
| 39 |
+
{['User Query', 'Safety Check', 'AI Intent Parsing', 'Smart Scraping', 'Review Analysis', 'Structured Results'].map((step, i) => (
|
| 40 |
+
<div key={i} className="pipeline-step">
|
| 41 |
+
<span className="pipeline-num">{i + 1}</span>
|
| 42 |
+
<span className="pipeline-label">{step}</span>
|
| 43 |
+
{i < 5 && <span className="pipeline-arrow">→</span>}
|
| 44 |
+
</div>
|
| 45 |
+
))}
|
| 46 |
+
</div>
|
| 47 |
+
</section>
|
| 48 |
+
|
| 49 |
+
<section className="about-section fade-in-up stagger-2">
|
| 50 |
+
<h3>Tech Stack</h3>
|
| 51 |
+
<div className="about-grid">
|
| 52 |
+
{TECH_STACK.map((t, i) => (
|
| 53 |
+
<div key={i} className="about-card">
|
| 54 |
+
<span className="about-card-icon">{t.icon}</span>
|
| 55 |
+
<h4>{t.name}</h4>
|
| 56 |
+
<p>{t.desc}</p>
|
| 57 |
+
</div>
|
| 58 |
+
))}
|
| 59 |
+
</div>
|
| 60 |
+
</section>
|
| 61 |
+
|
| 62 |
+
<section className="about-section fade-in-up stagger-3">
|
| 63 |
+
<h3>Backend Architecture</h3>
|
| 64 |
+
<div className="about-table-wrap">
|
| 65 |
+
<table className="about-table">
|
| 66 |
+
<thead>
|
| 67 |
+
<tr><th>Layer</th><th>Purpose</th><th>Why It Matters</th></tr>
|
| 68 |
+
</thead>
|
| 69 |
+
<tbody>
|
| 70 |
+
{ARCHITECTURE.map((a, i) => (
|
| 71 |
+
<tr key={i}>
|
| 72 |
+
<td className="about-table-layer">{a.layer}</td>
|
| 73 |
+
<td>{a.purpose}</td>
|
| 74 |
+
<td className="about-table-interview">{a.interview}</td>
|
| 75 |
+
</tr>
|
| 76 |
+
))}
|
| 77 |
+
</tbody>
|
| 78 |
+
</table>
|
| 79 |
+
</div>
|
| 80 |
+
</section>
|
| 81 |
+
|
| 82 |
+
<section className="about-section fade-in-up stagger-4">
|
| 83 |
+
<h3>Safety Philosophy</h3>
|
| 84 |
+
<div className="about-safety">
|
| 85 |
+
<div className="about-safety-item">
|
| 86 |
+
<span className="about-safety-icon">🚫</span>
|
| 87 |
+
<div>
|
| 88 |
+
<h4>Query Moderation</h4>
|
| 89 |
+
<p>Harmful, illegal, and adult content queries are blocked before they reach any service.</p>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
<div className="about-safety-item">
|
| 93 |
+
<span className="about-safety-icon">🔒</span>
|
| 94 |
+
<div>
|
| 95 |
+
<h4>Platform Restrictions</h4>
|
| 96 |
+
<p>Scraping is limited to publicly available data. Social media and private platforms are blocked.</p>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
<div className="about-safety-item">
|
| 100 |
+
<span className="about-safety-icon">⚖️</span>
|
| 101 |
+
<div>
|
| 102 |
+
<h4>User Responsibility</h4>
|
| 103 |
+
<p>Users agree to Terms of Use and accept responsibility for ethical, legal use of results.</p>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
</section>
|
| 108 |
+
</main>
|
| 109 |
+
);
|
| 110 |
+
}
|
client/src/app/globals.css
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
| 2 |
+
|
| 3 |
+
*,
|
| 4 |
+
*::before,
|
| 5 |
+
*::after {
|
| 6 |
+
margin: 0;
|
| 7 |
+
padding: 0;
|
| 8 |
+
box-sizing: border-box;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
:root {
|
| 12 |
+
--bg-primary: #850E35;
|
| 13 |
+
--bg-secondary: rgba(238, 105, 131, 0.2);
|
| 14 |
+
--bg-card: rgba(238, 105, 131, 0.15);
|
| 15 |
+
--bg-card-hover: rgba(238, 105, 131, 0.25);
|
| 16 |
+
--bg-input: rgba(255, 196, 196, 0.1);
|
| 17 |
+
|
| 18 |
+
--border-subtle: rgba(255, 196, 196, 0.2);
|
| 19 |
+
--border-focus: #FFC4C4;
|
| 20 |
+
|
| 21 |
+
--text-primary: #FCF5EE;
|
| 22 |
+
--text-secondary: #FFC4C4;
|
| 23 |
+
--text-muted: rgba(252, 245, 238, 0.65);
|
| 24 |
+
|
| 25 |
+
--accent-primary: #FFC4C4;
|
| 26 |
+
--accent-secondary: #FCF5EE;
|
| 27 |
+
--accent-tertiary: #EE6983;
|
| 28 |
+
--accent-gradient: linear-gradient(135deg, #FFC4C4 0%, #EE6983 100%);
|
| 29 |
+
--accent-glow: 0 0 20px rgba(255, 196, 196, 0.4);
|
| 30 |
+
|
| 31 |
+
--success: #2ecc71;
|
| 32 |
+
--warning: #f1c40f;
|
| 33 |
+
--danger: #e74c3c;
|
| 34 |
+
|
| 35 |
+
--radius-sm: 8px;
|
| 36 |
+
--radius-md: 12px;
|
| 37 |
+
--radius-lg: 16px;
|
| 38 |
+
--radius-xl: 24px;
|
| 39 |
+
|
| 40 |
+
--shadow-sm: 0 2px 8px rgba(133, 14, 53, 0.06);
|
| 41 |
+
--shadow-md: 0 4px 16px rgba(133, 14, 53, 0.1);
|
| 42 |
+
--shadow-lg: 0 8px 32px rgba(133, 14, 53, 0.15);
|
| 43 |
+
|
| 44 |
+
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 45 |
+
--transition-fast: 150ms ease;
|
| 46 |
+
--transition-normal: 250ms ease;
|
| 47 |
+
--transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
html {
|
| 51 |
+
font-size: 16px;
|
| 52 |
+
scroll-behavior: smooth;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
body {
|
| 56 |
+
font-family: var(--font-family);
|
| 57 |
+
background: var(--bg-primary);
|
| 58 |
+
color: var(--text-primary);
|
| 59 |
+
line-height: 1.6;
|
| 60 |
+
min-height: 100vh;
|
| 61 |
+
-webkit-font-smoothing: antialiased;
|
| 62 |
+
-moz-osx-font-smoothing: grayscale;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
#root {
|
| 66 |
+
min-height: 100vh;
|
| 67 |
+
display: flex;
|
| 68 |
+
flex-direction: column;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
::selection {
|
| 72 |
+
background: var(--accent-primary);
|
| 73 |
+
color: white;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
button {
|
| 77 |
+
cursor: pointer;
|
| 78 |
+
font-family: var(--font-family);
|
| 79 |
+
border: none;
|
| 80 |
+
outline: none;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
input,
|
| 84 |
+
select,
|
| 85 |
+
textarea {
|
| 86 |
+
font-family: var(--font-family);
|
| 87 |
+
outline: none;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
select {
|
| 91 |
+
appearance: none;
|
| 92 |
+
color: var(--text-primary);
|
| 93 |
+
background: #850E35;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/* Maintain visibility fix in dropdowns */
|
| 97 |
+
option {
|
| 98 |
+
background-color: #850E35;
|
| 99 |
+
color: var(--text-primary);
|
| 100 |
+
padding: 10px;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
@keyframes fadeInUp {
|
| 104 |
+
from {
|
| 105 |
+
opacity: 0;
|
| 106 |
+
transform: translateY(20px);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
to {
|
| 110 |
+
opacity: 1;
|
| 111 |
+
transform: translateY(0);
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
@keyframes spin {
|
| 116 |
+
to {
|
| 117 |
+
transform: rotate(360deg);
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.fade-in-up {
|
| 122 |
+
animation: fadeInUp var(--transition-slow) both;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.stagger-1 {
|
| 126 |
+
animation-delay: 0.1s;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.stagger-2 {
|
| 130 |
+
animation-delay: 0.2s;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.stagger-3 {
|
| 134 |
+
animation-delay: 0.3s;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.stagger-4 {
|
| 138 |
+
animation-delay: 0.4s;
|
| 139 |
+
}
|
client/src/app/history/page.jsx
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useRouter } from 'next/navigation';
|
| 4 |
+
import useSearchHistory from '../../hooks/useSearchHistory';
|
| 5 |
+
import { useToast } from '../../contexts/ToastContext';
|
| 6 |
+
import '../History.css';
|
| 7 |
+
|
| 8 |
+
export default function History() {
|
| 9 |
+
const { history, clearHistory, removeEntry } = useSearchHistory();
|
| 10 |
+
const router = useRouter();
|
| 11 |
+
const { addToast } = useToast();
|
| 12 |
+
|
| 13 |
+
function handleRerun(query) {
|
| 14 |
+
// Next.js App Router doesn't have state in navigate, using query params instead.
|
| 15 |
+
router.push(`/?rerunQuery=${encodeURIComponent(query)}`);
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function handleClear() {
|
| 19 |
+
clearHistory();
|
| 20 |
+
addToast('Search history cleared', 'success');
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function handleRemove(id) {
|
| 24 |
+
removeEntry(id);
|
| 25 |
+
addToast('Entry removed from history', 'info');
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function formatTime(iso) {
|
| 29 |
+
const d = new Date(iso);
|
| 30 |
+
const now = new Date();
|
| 31 |
+
const diffMs = now - d;
|
| 32 |
+
const diffMins = Math.floor(diffMs / 60000);
|
| 33 |
+
if (diffMins < 1) return 'Just now';
|
| 34 |
+
if (diffMins < 60) return `${diffMins}m ago`;
|
| 35 |
+
const diffHours = Math.floor(diffMins / 60);
|
| 36 |
+
if (diffHours < 24) return `${diffHours}h ago`;
|
| 37 |
+
return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' });
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
return (
|
| 41 |
+
<main className="history-page">
|
| 42 |
+
<div className="history-header fade-in-up">
|
| 43 |
+
<div>
|
| 44 |
+
<h2>Search History</h2>
|
| 45 |
+
<p className="history-subtitle">{history.length} search{history.length !== 1 ? 'es' : ''} saved locally</p>
|
| 46 |
+
</div>
|
| 47 |
+
{history.length > 0 && (
|
| 48 |
+
<button className="history-clear-btn" onClick={handleClear}>
|
| 49 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 50 |
+
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
| 51 |
+
</svg>
|
| 52 |
+
Clear All
|
| 53 |
+
</button>
|
| 54 |
+
)}
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
{history.length === 0 ? (
|
| 58 |
+
<div className="history-empty fade-in-up stagger-1">
|
| 59 |
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
|
| 60 |
+
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
| 61 |
+
</svg>
|
| 62 |
+
<p>No searches yet.</p>
|
| 63 |
+
<p className="history-empty-sub">Your searches will appear here once you start exploring.</p>
|
| 64 |
+
</div>
|
| 65 |
+
) : (
|
| 66 |
+
<div className="history-list">
|
| 67 |
+
{history.map((entry, i) => (
|
| 68 |
+
<div key={entry.id} className={`history-item fade-in-up stagger-${(i % 4) + 1}`}>
|
| 69 |
+
<div className="history-item-main">
|
| 70 |
+
<p className="history-item-query">"{entry.query}"</p>
|
| 71 |
+
<div className="history-item-meta">
|
| 72 |
+
{entry.intent?.category && (
|
| 73 |
+
<span className="history-chip">{entry.intent.category}</span>
|
| 74 |
+
)}
|
| 75 |
+
{entry.intent?.location && (
|
| 76 |
+
<span className="history-chip">{entry.intent.location}</span>
|
| 77 |
+
)}
|
| 78 |
+
<span className="history-item-results">{entry.resultCount} result{entry.resultCount !== 1 ? 's' : ''}</span>
|
| 79 |
+
<span className="history-item-time">{formatTime(entry.timestamp)}</span>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
<div className="history-item-actions">
|
| 83 |
+
<button className="history-rerun-btn" onClick={() => handleRerun(entry.query)} title="Search again">
|
| 84 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
| 85 |
+
<polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
| 86 |
+
</svg>
|
| 87 |
+
</button>
|
| 88 |
+
<button className="history-remove-btn" onClick={() => handleRemove(entry.id)} title="Remove">
|
| 89 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 90 |
+
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
| 91 |
+
</svg>
|
| 92 |
+
</button>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
))}
|
| 96 |
+
</div>
|
| 97 |
+
)}
|
| 98 |
+
</main>
|
| 99 |
+
);
|
| 100 |
+
}
|
client/src/app/layout.jsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import './globals.css';
|
| 2 |
+
import Header from '../components/Header';
|
| 3 |
+
import { Providers } from '../components/Providers';
|
| 4 |
+
|
| 5 |
+
export const metadata = {
|
| 6 |
+
title: 'RedThread - AI-assisted location-based recommendations',
|
| 7 |
+
description: 'Find the perfect spot, powered by AI.',
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
export default function RootLayout({ children }) {
|
| 11 |
+
return (
|
| 12 |
+
<html lang="en">
|
| 13 |
+
<body suppressHydrationWarning={true}>
|
| 14 |
+
<Providers>
|
| 15 |
+
<Header />
|
| 16 |
+
{children}
|
| 17 |
+
</Providers>
|
| 18 |
+
</body>
|
| 19 |
+
</html>
|
| 20 |
+
);
|
| 21 |
+
}
|
client/src/app/not-found.jsx
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import Link from 'next/link';
|
| 4 |
+
import './NotFound.css';
|
| 5 |
+
|
| 6 |
+
export default function NotFound() {
|
| 7 |
+
return (
|
| 8 |
+
<main className="not-found fade-in-up">
|
| 9 |
+
<div className="not-found-content">
|
| 10 |
+
<div className="not-found-icon">
|
| 11 |
+
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
|
| 12 |
+
<circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" />
|
| 13 |
+
</svg>
|
| 14 |
+
</div>
|
| 15 |
+
<h2>404 - Page Not Found</h2>
|
| 16 |
+
<p>The page you're looking for doesn't exist or has been moved.</p>
|
| 17 |
+
<Link href="/" className="btn-primary">
|
| 18 |
+
Return Home
|
| 19 |
+
</Link>
|
| 20 |
+
</div>
|
| 21 |
+
</main>
|
| 22 |
+
);
|
| 23 |
+
}
|
client/src/app/page.jsx
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect, useMemo } from 'react';
|
| 4 |
+
import { useSearchParams } from 'next/navigation';
|
| 5 |
+
import SearchBar from '../components/SearchBar';
|
| 6 |
+
import ResultCard from '../components/ResultCard';
|
| 7 |
+
import SafetyBanner from '../components/SafetyBanner';
|
| 8 |
+
import FilterPanel from '../components/FilterPanel';
|
| 9 |
+
import SkeletonCard from '../components/SkeletonCard';
|
| 10 |
+
import ResultModal from '../components/ResultModal';
|
| 11 |
+
import ClarificationPrompt from '../components/ClarificationPrompt';
|
| 12 |
+
import useSearchHistory from '../hooks/useSearchHistory';
|
| 13 |
+
import { useToast } from '../contexts/ToastContext';
|
| 14 |
+
import { search } from '../api/client';
|
| 15 |
+
import { useGeolocation } from '../hooks/useGeolocation';
|
| 16 |
+
import './Home.css';
|
| 17 |
+
|
| 18 |
+
const DEFAULT_FILTERS = { category: 'all', maxBudget: '', features: [], sortBy: 'relevance' };
|
| 19 |
+
|
| 20 |
+
export default function Home() {
|
| 21 |
+
const [results, setResults] = useState([]);
|
| 22 |
+
const [intent, setIntent] = useState(null);
|
| 23 |
+
const [loading, setLoading] = useState(false);
|
| 24 |
+
const [error, setError] = useState(null);
|
| 25 |
+
const [meta, setMeta] = useState(null);
|
| 26 |
+
const [loadingMsg, setLoadingMsg] = useState('Parsing intent...');
|
| 27 |
+
const [hasSearched, setHasSearched] = useState(false);
|
| 28 |
+
const [filters, setFilters] = useState({});
|
| 29 |
+
const [dynamicFilters, setDynamicFilters] = useState([]);
|
| 30 |
+
const [selectedResult, setSelectedResult] = useState(null);
|
| 31 |
+
const [scopeMessage, setScopeMessage] = useState(null);
|
| 32 |
+
const [clarification, setClarification] = useState(null);
|
| 33 |
+
|
| 34 |
+
const { addEntry, history } = useSearchHistory();
|
| 35 |
+
const { addToast } = useToast();
|
| 36 |
+
const searchParams = useSearchParams();
|
| 37 |
+
const { location: userLocation, error: locationError, isLoading: locationLoading, requestLocation, clearLocation } = useGeolocation();
|
| 38 |
+
|
| 39 |
+
useEffect(() => {
|
| 40 |
+
const rerunQuery = searchParams.get('rerunQuery');
|
| 41 |
+
if (rerunQuery) {
|
| 42 |
+
handleSearch(rerunQuery);
|
| 43 |
+
// In Next.js, updating the URL without reloading is done via router.replace
|
| 44 |
+
// but for now, we'll just handle the search.
|
| 45 |
+
}
|
| 46 |
+
}, [searchParams]);
|
| 47 |
+
|
| 48 |
+
const handleClarificationSubmit = (answer) => {
|
| 49 |
+
if (clarification) {
|
| 50 |
+
const context = {
|
| 51 |
+
originalQuery: clarification.originalQuery,
|
| 52 |
+
question: clarification.question,
|
| 53 |
+
answer: answer
|
| 54 |
+
};
|
| 55 |
+
setClarification(null);
|
| 56 |
+
handleSearch(clarification.originalQuery, context);
|
| 57 |
+
}
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
const handleClarificationCancel = () => {
|
| 61 |
+
setClarification(null);
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
async function handleSearch(query, clarificationContext = null) {
|
| 65 |
+
setLoading(true);
|
| 66 |
+
setLoadingMsg('Searching...');
|
| 67 |
+
setError(null);
|
| 68 |
+
setScopeMessage(null);
|
| 69 |
+
setHasSearched(true);
|
| 70 |
+
|
| 71 |
+
// Show auto-scraping message if backend takes longer than 3 seconds
|
| 72 |
+
const timeoutId = setTimeout(() => {
|
| 73 |
+
setLoadingMsg('Scraping fresh data from the web (this may take 10-15s)...');
|
| 74 |
+
}, 3000);
|
| 75 |
+
|
| 76 |
+
try {
|
| 77 |
+
// Include `userLocation` dynamically if the user has requested it
|
| 78 |
+
const data = await search(query, null, filters, userLocation, clarificationContext);
|
| 79 |
+
clearTimeout(timeoutId);
|
| 80 |
+
|
| 81 |
+
if (data.isOutOfScope) {
|
| 82 |
+
setScopeMessage(data.scopeMessage);
|
| 83 |
+
setResults([]);
|
| 84 |
+
setIntent(data.intent || null);
|
| 85 |
+
setMeta(null);
|
| 86 |
+
setLoading(false);
|
| 87 |
+
return;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
if (data.needsClarification) {
|
| 91 |
+
setClarification({ originalQuery: query, question: data.clarificationQuestion });
|
| 92 |
+
setResults([]);
|
| 93 |
+
setIntent(null);
|
| 94 |
+
setMeta(null);
|
| 95 |
+
setLoading(false);
|
| 96 |
+
return;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
setClarification(null);
|
| 100 |
+
|
| 101 |
+
const res = data.results || [];
|
| 102 |
+
setResults(res);
|
| 103 |
+
setIntent(data.intent || null);
|
| 104 |
+
setMeta(data.meta || null);
|
| 105 |
+
setDynamicFilters(data.dynamicFilters || []);
|
| 106 |
+
setFilters({});
|
| 107 |
+
addEntry(query, data.intent, res.length);
|
| 108 |
+
} catch (err) {
|
| 109 |
+
clearTimeout(timeoutId);
|
| 110 |
+
const errorMsg = err.data?.error || err.message || 'Something went wrong';
|
| 111 |
+
setError(errorMsg);
|
| 112 |
+
addToast(errorMsg, 'error');
|
| 113 |
+
setResults([]);
|
| 114 |
+
setIntent(null);
|
| 115 |
+
} finally {
|
| 116 |
+
setLoading(false);
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
const filteredResults = useMemo(() => {
|
| 121 |
+
let filtered = [...results];
|
| 122 |
+
|
| 123 |
+
dynamicFilters.forEach(schema => {
|
| 124 |
+
const val = filters[schema.id];
|
| 125 |
+
if (!val) return;
|
| 126 |
+
|
| 127 |
+
if (schema.type === 'range') {
|
| 128 |
+
const max = parseInt(val, 10);
|
| 129 |
+
filtered = filtered.filter(r => {
|
| 130 |
+
const match = r.priceRange?.match(/([₹$£€])(\d+)/);
|
| 131 |
+
return match ? parseInt(match[2], 10) <= max : true;
|
| 132 |
+
});
|
| 133 |
+
} else if (schema.type === 'select') {
|
| 134 |
+
if (val.length > 0) {
|
| 135 |
+
filtered = filtered.filter(r =>
|
| 136 |
+
val.every(selectedOpt => {
|
| 137 |
+
const optL = selectedOpt.toLowerCase();
|
| 138 |
+
const inFeatures = (r.features || []).some(f => f.toLowerCase().includes(optL));
|
| 139 |
+
const inName = r.name?.toLowerCase().includes(optL);
|
| 140 |
+
const inSummary = r.reviewSummary?.toLowerCase().includes(optL);
|
| 141 |
+
const inCat = r.category?.toLowerCase().includes(optL);
|
| 142 |
+
return inFeatures || inName || inSummary || inCat;
|
| 143 |
+
})
|
| 144 |
+
);
|
| 145 |
+
}
|
| 146 |
+
} else if (schema.type === 'sort') {
|
| 147 |
+
if (val === 'rating') {
|
| 148 |
+
filtered.sort((a, b) => (b.rating === 'N/A' ? 0 : parseFloat(b.rating)) - (a.rating === 'N/A' ? 0 : parseFloat(a.rating)));
|
| 149 |
+
} else if (val === 'price_low') {
|
| 150 |
+
filtered.sort((a, b) => {
|
| 151 |
+
const pa = parseInt(a.priceRange?.match(/([₹$£€])(\d+)/)?.[2] || '0', 10);
|
| 152 |
+
const pb = parseInt(b.priceRange?.match(/([₹$£€])(\d+)/)?.[2] || '0', 10);
|
| 153 |
+
if (!pa) return 1; if (!pb) return -1;
|
| 154 |
+
return pa - pb;
|
| 155 |
+
});
|
| 156 |
+
} else if (val === 'price_high') {
|
| 157 |
+
filtered.sort((a, b) => {
|
| 158 |
+
const pa = parseInt(a.priceRange?.match(/([₹$£€])(\d+)/)?.[2] || '0', 10);
|
| 159 |
+
const pb = parseInt(b.priceRange?.match(/([₹$£€])(\d+)/)?.[2] || '0', 10);
|
| 160 |
+
return pb - pa;
|
| 161 |
+
});
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
});
|
| 165 |
+
|
| 166 |
+
return filtered;
|
| 167 |
+
}, [results, filters, dynamicFilters]);
|
| 168 |
+
|
| 169 |
+
return (
|
| 170 |
+
<main className="home">
|
| 171 |
+
<section className="home-hero">
|
| 172 |
+
<div className="home-hero-glow"></div>
|
| 173 |
+
<h2 className="home-headline fade-in-up">
|
| 174 |
+
AI that <span className="home-headline-accent">thinks</span><br />
|
| 175 |
+
before it searches
|
| 176 |
+
</h2>
|
| 177 |
+
<p className="home-subline fade-in-up stagger-1">
|
| 178 |
+
Describe what you want naturally — RedThread interprets your intent,
|
| 179 |
+
adapts to context, and finds results that actually match.
|
| 180 |
+
</p>
|
| 181 |
+
<div className="fade-in-up stagger-2" style={{ position: 'relative', zIndex: 10 }}>
|
| 182 |
+
<SearchBar
|
| 183 |
+
onSearch={handleSearch}
|
| 184 |
+
userLocation={userLocation}
|
| 185 |
+
locationLoading={locationLoading}
|
| 186 |
+
locationError={locationError}
|
| 187 |
+
requestLocation={requestLocation}
|
| 188 |
+
clearLocation={clearLocation}
|
| 189 |
+
loading={loading}
|
| 190 |
+
/>
|
| 191 |
+
</div>
|
| 192 |
+
<div className="fade-in-up stagger-3" style={{ position: 'relative', zIndex: 1, marginTop: '0.75rem' }}>
|
| 193 |
+
<SafetyBanner />
|
| 194 |
+
</div>
|
| 195 |
+
{!hasSearched && history.length > 0 && (
|
| 196 |
+
<div className="home-recent-searches fade-in-up stagger-4">
|
| 197 |
+
<span className="recent-label">Your Recent Searches:</span>
|
| 198 |
+
<div className="recent-links">
|
| 199 |
+
{history.slice(0, 3).map((entry) => (
|
| 200 |
+
<button
|
| 201 |
+
key={entry.id}
|
| 202 |
+
className="recent-link-btn"
|
| 203 |
+
onClick={() => handleSearch(entry.query)}
|
| 204 |
+
>
|
| 205 |
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
| 206 |
+
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
| 207 |
+
</svg>
|
| 208 |
+
{entry.query}
|
| 209 |
+
</button>
|
| 210 |
+
))}
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
)}
|
| 214 |
+
{clarification && !loading && (
|
| 215 |
+
<div className="home-clarification-wrapper fade-in-up stagger-2" style={{ position: 'relative', zIndex: 20 }}>
|
| 216 |
+
<ClarificationPrompt
|
| 217 |
+
question={clarification.question}
|
| 218 |
+
onSubmit={handleClarificationSubmit}
|
| 219 |
+
onCancel={handleClarificationCancel}
|
| 220 |
+
/>
|
| 221 |
+
</div>
|
| 222 |
+
)}
|
| 223 |
+
</section>
|
| 224 |
+
|
| 225 |
+
{scopeMessage && !loading && (
|
| 226 |
+
<div className="home-scope-guidance fade-in-up">
|
| 227 |
+
<div className="scope-icon">💡</div>
|
| 228 |
+
<div className="scope-content">
|
| 229 |
+
<h4>Platform Guidance</h4>
|
| 230 |
+
<p>{scopeMessage}</p>
|
| 231 |
+
</div>
|
| 232 |
+
</div>
|
| 233 |
+
)}
|
| 234 |
+
|
| 235 |
+
{error && (
|
| 236 |
+
<div className="home-error fade-in-up">
|
| 237 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 238 |
+
<circle cx="12" cy="12" r="10" />
|
| 239 |
+
<line x1="15" y1="9" x2="9" y2="15" />
|
| 240 |
+
<line x1="9" y1="9" x2="15" y2="15" />
|
| 241 |
+
</svg>
|
| 242 |
+
<span style={{ flex: 1 }}>{error}</span>
|
| 243 |
+
{error.toLowerCase().includes('location') && !userLocation && (
|
| 244 |
+
<button
|
| 245 |
+
type="button"
|
| 246 |
+
className="error-action-btn"
|
| 247 |
+
onClick={requestLocation}
|
| 248 |
+
>
|
| 249 |
+
Use my location
|
| 250 |
+
</button>
|
| 251 |
+
)}
|
| 252 |
+
</div>
|
| 253 |
+
)}
|
| 254 |
+
|
| 255 |
+
{intent && !error && (
|
| 256 |
+
<section className="home-intent fade-in-up">
|
| 257 |
+
<h4 className="home-intent-title">Parsed Intent</h4>
|
| 258 |
+
<div className="home-intent-chips">
|
| 259 |
+
<span className="intent-chip reasoning">
|
| 260 |
+
<strong>AI Reasoning:</strong> {intent.reasoning}
|
| 261 |
+
</span>
|
| 262 |
+
<span className="intent-chip">
|
| 263 |
+
<strong>Category:</strong> {intent.category}
|
| 264 |
+
</span>
|
| 265 |
+
{intent.location && (
|
| 266 |
+
<span className="intent-chip">
|
| 267 |
+
<strong>Location:</strong> {intent.location}
|
| 268 |
+
</span>
|
| 269 |
+
)}
|
| 270 |
+
{intent.budget?.max && (
|
| 271 |
+
<span className="intent-chip">
|
| 272 |
+
<strong>Budget:</strong> up to {intent.budget.currency}{intent.budget.max}
|
| 273 |
+
</span>
|
| 274 |
+
)}
|
| 275 |
+
{intent.occasion && (
|
| 276 |
+
<span className="intent-chip">
|
| 277 |
+
<strong>Occasion:</strong> {intent.occasion}
|
| 278 |
+
</span>
|
| 279 |
+
)}
|
| 280 |
+
</div>
|
| 281 |
+
</section>
|
| 282 |
+
)}
|
| 283 |
+
|
| 284 |
+
{loading && (
|
| 285 |
+
<section className="home-results">
|
| 286 |
+
<div className="home-results-header">
|
| 287 |
+
<h3>{loadingMsg}</h3>
|
| 288 |
+
</div>
|
| 289 |
+
<div className="home-results-grid">
|
| 290 |
+
{[1, 2, 3, 4].map(i => <SkeletonCard key={i} />)}
|
| 291 |
+
</div>
|
| 292 |
+
</section>
|
| 293 |
+
)}
|
| 294 |
+
|
| 295 |
+
{!loading && hasSearched && results.length > 0 && (
|
| 296 |
+
<section className="home-results-layout">
|
| 297 |
+
<FilterPanel
|
| 298 |
+
filters={filters}
|
| 299 |
+
onChange={setFilters}
|
| 300 |
+
dynamicFilters={dynamicFilters}
|
| 301 |
+
resultCount={filteredResults.length}
|
| 302 |
+
/>
|
| 303 |
+
<section className="home-results">
|
| 304 |
+
<div className="home-results-header">
|
| 305 |
+
<h3>Results</h3>
|
| 306 |
+
{meta && (
|
| 307 |
+
<span className="home-results-meta">
|
| 308 |
+
{filteredResults.length} of {meta.total} · {meta.source}
|
| 309 |
+
</span>
|
| 310 |
+
)}
|
| 311 |
+
</div>
|
| 312 |
+
<div className="home-results-grid">
|
| 313 |
+
{filteredResults.map((r, i) => (
|
| 314 |
+
<ResultCard
|
| 315 |
+
key={i}
|
| 316 |
+
result={{ ...r, cached: meta?.cached }}
|
| 317 |
+
index={i}
|
| 318 |
+
onClick={() => setSelectedResult(r)}
|
| 319 |
+
/>
|
| 320 |
+
))}
|
| 321 |
+
</div>
|
| 322 |
+
{filteredResults.length === 0 && (
|
| 323 |
+
<div className="home-empty">
|
| 324 |
+
<p>No results match your filters. Try adjusting them.</p>
|
| 325 |
+
</div>
|
| 326 |
+
)}
|
| 327 |
+
</section>
|
| 328 |
+
</section>
|
| 329 |
+
)}
|
| 330 |
+
|
| 331 |
+
{hasSearched && !loading && results.length === 0 && !error && (
|
| 332 |
+
<div className="home-empty fade-in-up">
|
| 333 |
+
<p>No results found. Try a different query or location.</p>
|
| 334 |
+
</div>
|
| 335 |
+
)}
|
| 336 |
+
|
| 337 |
+
{selectedResult && (
|
| 338 |
+
<ResultModal result={selectedResult} onClose={() => setSelectedResult(null)} />
|
| 339 |
+
)}
|
| 340 |
+
</main>
|
| 341 |
+
);
|
| 342 |
+
}
|
client/src/app/terms/Terms.css
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.terms-container {
|
| 2 |
+
max-width: 800px;
|
| 3 |
+
margin: 40px auto;
|
| 4 |
+
padding: 40px;
|
| 5 |
+
background: var(--bg-card);
|
| 6 |
+
border: 1px solid var(--border-subtle);
|
| 7 |
+
border-radius: var(--radius-lg);
|
| 8 |
+
box-shadow: var(--shadow-lg);
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
.terms-header {
|
| 12 |
+
margin-bottom: 40px;
|
| 13 |
+
text-align: center;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
.terms-header h1 {
|
| 17 |
+
font-size: 2.5rem;
|
| 18 |
+
color: var(--text-primary);
|
| 19 |
+
margin-bottom: 15px;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
.terms-last-updated {
|
| 23 |
+
font-size: 0.9rem;
|
| 24 |
+
color: var(--text-muted);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
.terms-section {
|
| 28 |
+
margin-bottom: 30px;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.terms-section h2 {
|
| 32 |
+
font-size: 1.5rem;
|
| 33 |
+
color: var(--accent-primary);
|
| 34 |
+
margin-bottom: 15px;
|
| 35 |
+
border-bottom: 1px solid var(--border-subtle);
|
| 36 |
+
padding-bottom: 10px;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.terms-section p {
|
| 40 |
+
font-size: 1rem;
|
| 41 |
+
line-height: 1.8;
|
| 42 |
+
color: var(--text-secondary);
|
| 43 |
+
margin-bottom: 15px;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
.terms-section ul {
|
| 47 |
+
list-style: none;
|
| 48 |
+
padding-left: 0;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.terms-section li {
|
| 52 |
+
position: relative;
|
| 53 |
+
padding-left: 25px;
|
| 54 |
+
margin-bottom: 12px;
|
| 55 |
+
color: var(--text-secondary);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.terms-section li::before {
|
| 59 |
+
content: "→";
|
| 60 |
+
position: absolute;
|
| 61 |
+
left: 0;
|
| 62 |
+
color: var(--accent-tertiary);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.terms-footer {
|
| 66 |
+
margin-top: 50px;
|
| 67 |
+
padding-top: 30px;
|
| 68 |
+
border-top: 1px solid var(--border-subtle);
|
| 69 |
+
text-align: center;
|
| 70 |
+
color: var(--text-muted);
|
| 71 |
+
}
|
client/src/app/terms/page.jsx
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import './Terms.css';
|
| 4 |
+
import Link from 'next/link';
|
| 5 |
+
|
| 6 |
+
export default function Terms() {
|
| 7 |
+
return (
|
| 8 |
+
<main className="terms-container fade-in-up">
|
| 9 |
+
<header className="terms-header">
|
| 10 |
+
<h1>Terms of Use</h1>
|
| 11 |
+
<p className="terms-last-updated">Last Updated: March 2026</p>
|
| 12 |
+
</header>
|
| 13 |
+
|
| 14 |
+
<section className="terms-section">
|
| 15 |
+
<h2>1. Acceptance of Terms</h2>
|
| 16 |
+
<p>
|
| 17 |
+
By accessing and using RedThread, you agree to be bound by these Terms of Use and all applicable laws and regulations. If you do not agree with any of these terms, you are prohibited from using or accessing this site.
|
| 18 |
+
</p>
|
| 19 |
+
</section>
|
| 20 |
+
|
| 21 |
+
<section className="terms-section">
|
| 22 |
+
<h2>2. Ethical AI Use</h2>
|
| 23 |
+
<p>
|
| 24 |
+
RedThread is designed to be a helpful assistant for discovering location-based information. Users are strictly prohibited from using the platform for:
|
| 25 |
+
</p>
|
| 26 |
+
<ul>
|
| 27 |
+
<li>Generating or promoting harmful, illegal, or adult content.</li>
|
| 28 |
+
<li>Stalking, harassment, or any form of malicious monitoring.</li>
|
| 29 |
+
<li>Automated bulk scraping beyond normal individual research use.</li>
|
| 30 |
+
<li>Attempting to bypass safety filters or platform restrictions.</li>
|
| 31 |
+
</ul>
|
| 32 |
+
</section>
|
| 33 |
+
|
| 34 |
+
<section className="terms-section">
|
| 35 |
+
<h2>3. Data & Privacy</h2>
|
| 36 |
+
<p>
|
| 37 |
+
We value your privacy. RedThread processes your queries to provide relevant recommendations. While we strive for accuracy, AI-generated content may occasionally contain errors. Always verify critical information directly with the service provider.
|
| 38 |
+
</p>
|
| 39 |
+
</section>
|
| 40 |
+
|
| 41 |
+
<section className="terms-section">
|
| 42 |
+
<h2>4. Intellectual Property</h2>
|
| 43 |
+
<p>
|
| 44 |
+
The technology, design, and "RedThread" brand are the intellectual property of its creators. Users are granted a limited license for personal, non-commercial use of the search results.
|
| 45 |
+
</p>
|
| 46 |
+
</section>
|
| 47 |
+
|
| 48 |
+
<section className="terms-section">
|
| 49 |
+
<h2>5. Disclaimer</h2>
|
| 50 |
+
<p>
|
| 51 |
+
The materials on RedThread are provided on an 'as is' basis. RedThread makes no warranties, expressed or implied, and hereby disclaims and negates all other warranties including, without limitation, implied warranties or conditions of merchantability, or fitness for a particular purpose.
|
| 52 |
+
</p>
|
| 53 |
+
</section>
|
| 54 |
+
|
| 55 |
+
<footer className="terms-footer">
|
| 56 |
+
<Link href="/" className="accent-text">Return to Search</Link>
|
| 57 |
+
</footer>
|
| 58 |
+
</main>
|
| 59 |
+
);
|
| 60 |
+
}
|
client/src/assets/react.svg
ADDED
|
|
client/src/components/ClarificationPrompt.css
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.clarification-prompt {
|
| 2 |
+
background: var(--bg-secondary);
|
| 3 |
+
border: 1px solid var(--border-subtle);
|
| 4 |
+
border-radius: var(--radius-lg);
|
| 5 |
+
padding: 1.5rem;
|
| 6 |
+
max-width: 720px;
|
| 7 |
+
margin: 1rem auto;
|
| 8 |
+
box-shadow: var(--shadow-md);
|
| 9 |
+
position: relative;
|
| 10 |
+
z-index: 15;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
.clarification-header {
|
| 14 |
+
display: flex;
|
| 15 |
+
align-items: center;
|
| 16 |
+
gap: 0.5rem;
|
| 17 |
+
margin-bottom: 1rem;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
.clarification-icon {
|
| 21 |
+
font-size: 1.25rem;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.clarification-title {
|
| 25 |
+
font-weight: 600;
|
| 26 |
+
color: var(--primary-color);
|
| 27 |
+
font-size: 0.95rem;
|
| 28 |
+
text-transform: uppercase;
|
| 29 |
+
letter-spacing: 0.05em;
|
| 30 |
+
flex: 1;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.clarification-close {
|
| 34 |
+
background: none;
|
| 35 |
+
border: none;
|
| 36 |
+
color: var(--text-muted);
|
| 37 |
+
font-size: 1.5rem;
|
| 38 |
+
cursor: pointer;
|
| 39 |
+
line-height: 1;
|
| 40 |
+
padding: 0;
|
| 41 |
+
transition: color var(--transition-fast);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.clarification-close:hover {
|
| 45 |
+
color: #ff7675;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.clarification-question {
|
| 49 |
+
font-size: 1.05rem;
|
| 50 |
+
color: var(--text-primary);
|
| 51 |
+
line-height: 1.5;
|
| 52 |
+
margin-bottom: 1.25rem;
|
| 53 |
+
padding-left: 0.5rem;
|
| 54 |
+
border-left: 3px solid rgba(238, 105, 131, 0.4);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.clarification-form {
|
| 58 |
+
display: flex;
|
| 59 |
+
gap: 0.75rem;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
.clarification-input {
|
| 63 |
+
flex: 1;
|
| 64 |
+
padding: 0.75rem 1rem;
|
| 65 |
+
background: var(--bg-input);
|
| 66 |
+
border: 1px solid var(--border-subtle);
|
| 67 |
+
border-radius: var(--radius-md);
|
| 68 |
+
color: var(--text-primary);
|
| 69 |
+
font-size: 0.95rem;
|
| 70 |
+
transition: all var(--transition-normal);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.clarification-input:focus {
|
| 74 |
+
border-color: var(--border-focus);
|
| 75 |
+
box-shadow: 0 0 0 3px rgba(238, 105, 131, 0.15);
|
| 76 |
+
outline: none;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.clarification-btn {
|
| 80 |
+
display: flex;
|
| 81 |
+
align-items: center;
|
| 82 |
+
gap: 0.5rem;
|
| 83 |
+
padding: 0.75rem 1.25rem;
|
| 84 |
+
background: rgba(238, 105, 131, 0.15);
|
| 85 |
+
color: var(--primary-color);
|
| 86 |
+
font-weight: 600;
|
| 87 |
+
border: 1px solid rgba(238, 105, 131, 0.3);
|
| 88 |
+
border-radius: var(--radius-md);
|
| 89 |
+
cursor: pointer;
|
| 90 |
+
transition: all var(--transition-fast);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.clarification-btn:hover:not(:disabled) {
|
| 94 |
+
background: rgba(238, 105, 131, 0.25);
|
| 95 |
+
text-shadow: 0 0 8px rgba(238, 105, 131, 0.4);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.clarification-btn:disabled {
|
| 99 |
+
opacity: 0.5;
|
| 100 |
+
cursor: not-allowed;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
@media (max-width: 600px) {
|
| 104 |
+
.clarification-form {
|
| 105 |
+
flex-direction: column;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
.clarification-btn {
|
| 109 |
+
justify-content: center;
|
| 110 |
+
}
|
| 111 |
+
}
|
client/src/components/ClarificationPrompt.jsx
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef, useEffect } from 'react';
|
| 2 |
+
import './ClarificationPrompt.css';
|
| 3 |
+
|
| 4 |
+
export default function ClarificationPrompt({ question, onSubmit, onCancel }) {
|
| 5 |
+
const [answer, setAnswer] = useState('');
|
| 6 |
+
const inputRef = useRef(null);
|
| 7 |
+
|
| 8 |
+
useEffect(() => {
|
| 9 |
+
if (inputRef.current) {
|
| 10 |
+
inputRef.current.focus();
|
| 11 |
+
}
|
| 12 |
+
}, [question]);
|
| 13 |
+
|
| 14 |
+
const handleSubmit = (e) => {
|
| 15 |
+
e.preventDefault();
|
| 16 |
+
if (answer.trim()) {
|
| 17 |
+
onSubmit(answer.trim());
|
| 18 |
+
}
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<div className="clarification-prompt fade-in-up">
|
| 23 |
+
<div className="clarification-header">
|
| 24 |
+
<span className="clarification-icon">🤖</span>
|
| 25 |
+
<span className="clarification-title">Clarification Needed</span>
|
| 26 |
+
<button type="button" className="clarification-close" onClick={onCancel} title="Cancel search">
|
| 27 |
+
×
|
| 28 |
+
</button>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
<p className="clarification-question">{question}</p>
|
| 32 |
+
|
| 33 |
+
<form className="clarification-form" onSubmit={handleSubmit}>
|
| 34 |
+
<input
|
| 35 |
+
ref={inputRef}
|
| 36 |
+
type="text"
|
| 37 |
+
className="clarification-input"
|
| 38 |
+
placeholder="Type your answer..."
|
| 39 |
+
value={answer}
|
| 40 |
+
onChange={(e) => setAnswer(e.target.value)}
|
| 41 |
+
/>
|
| 42 |
+
<button type="submit" className="clarification-btn" disabled={!answer.trim()}>
|
| 43 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
| 44 |
+
<line x1="22" y1="2" x2="11" y2="13" />
|
| 45 |
+
<polygon points="22 2 15 22 11 13 2 9 22 2" />
|
| 46 |
+
</svg>
|
| 47 |
+
Send
|
| 48 |
+
</button>
|
| 49 |
+
</form>
|
| 50 |
+
</div>
|
| 51 |
+
);
|
| 52 |
+
}
|
client/src/components/ErrorBoundary.css
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.error-boundary-container {
|
| 2 |
+
min-height: 100vh;
|
| 3 |
+
display: flex;
|
| 4 |
+
align-items: center;
|
| 5 |
+
justify-content: center;
|
| 6 |
+
padding: 2rem;
|
| 7 |
+
background: var(--bg-primary);
|
| 8 |
+
text-align: center;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
.error-boundary-content {
|
| 12 |
+
max-width: 480px;
|
| 13 |
+
background: var(--bg-card);
|
| 14 |
+
padding: 3rem 2rem;
|
| 15 |
+
border-radius: var(--radius-lg);
|
| 16 |
+
border: 1px solid var(--border-subtle);
|
| 17 |
+
box-shadow: var(--shadow-xl);
|
| 18 |
+
display: flex;
|
| 19 |
+
flex-direction: column;
|
| 20 |
+
align-items: center;
|
| 21 |
+
gap: 1.5rem;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.error-boundary-icon {
|
| 25 |
+
color: #ff7675;
|
| 26 |
+
filter: drop-shadow(0 0 12px rgba(255, 118, 117, 0.2));
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
.error-boundary-content h2 {
|
| 30 |
+
font-size: 1.5rem;
|
| 31 |
+
color: var(--text-primary);
|
| 32 |
+
margin-bottom: 0.5rem;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
.error-boundary-content p {
|
| 36 |
+
color: var(--text-secondary);
|
| 37 |
+
font-size: 0.95rem;
|
| 38 |
+
line-height: 1.5;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.error-boundary-btn {
|
| 42 |
+
margin-top: 1rem;
|
| 43 |
+
padding: 0.75rem 2rem;
|
| 44 |
+
background: var(--bg-secondary);
|
| 45 |
+
color: var(--text-primary);
|
| 46 |
+
border: 1px solid var(--border-subtle);
|
| 47 |
+
border-radius: var(--radius-md);
|
| 48 |
+
font-weight: 600;
|
| 49 |
+
cursor: pointer;
|
| 50 |
+
transition: all var(--transition-normal);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.error-boundary-btn:hover {
|
| 54 |
+
background: var(--bg-input);
|
| 55 |
+
border-color: var(--border-hover);
|
| 56 |
+
transform: translateY(-2px);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.error-boundary-details {
|
| 60 |
+
margin-top: 1.5rem;
|
| 61 |
+
text-align: left;
|
| 62 |
+
background: rgba(0, 0, 0, 0.2);
|
| 63 |
+
padding: 1rem;
|
| 64 |
+
border-radius: var(--radius-sm);
|
| 65 |
+
font-size: 0.75rem;
|
| 66 |
+
color: var(--text-muted);
|
| 67 |
+
width: 100%;
|
| 68 |
+
overflow-x: auto;
|
| 69 |
+
border: 1px solid rgba(255, 118, 117, 0.1);
|
| 70 |
+
}
|
client/src/components/ErrorBoundary.jsx
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import './ErrorBoundary.css';
|
| 3 |
+
|
| 4 |
+
class ErrorBoundary extends React.Component {
|
| 5 |
+
constructor(props) {
|
| 6 |
+
super(props);
|
| 7 |
+
this.state = { hasError: false, error: null };
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
static getDerivedStateFromError(error) {
|
| 11 |
+
return { hasError: true, error };
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
componentDidCatch(error, errorInfo) {
|
| 15 |
+
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
render() {
|
| 19 |
+
if (this.state.hasError) {
|
| 20 |
+
return (
|
| 21 |
+
<div className="error-boundary-container">
|
| 22 |
+
<div className="error-boundary-content fade-in-up">
|
| 23 |
+
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="error-boundary-icon">
|
| 24 |
+
<polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2" />
|
| 25 |
+
<line x1="12" y1="8" x2="12" y2="12" />
|
| 26 |
+
<line x1="12" y1="16" x2="12.01" y2="16" />
|
| 27 |
+
</svg>
|
| 28 |
+
<h2>Something went wrong.</h2>
|
| 29 |
+
<p>We've encountered an unexpected error. Our team has been notified.</p>
|
| 30 |
+
<button
|
| 31 |
+
className="error-boundary-btn"
|
| 32 |
+
onClick={() => window.location.reload()}
|
| 33 |
+
>
|
| 34 |
+
Reload Page
|
| 35 |
+
</button>
|
| 36 |
+
{process.env.NODE_ENV === 'development' && (
|
| 37 |
+
<pre className="error-boundary-details">{this.state.error?.toString()}</pre>
|
| 38 |
+
)}
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
return this.props.children;
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export default ErrorBoundary;
|
client/src/components/FilterPanel.css
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.filter-panel {
|
| 2 |
+
background: var(--bg-card);
|
| 3 |
+
border: 1px solid var(--border-subtle);
|
| 4 |
+
border-radius: var(--radius-lg);
|
| 5 |
+
overflow: hidden;
|
| 6 |
+
margin-bottom: 1.5rem;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
.filter-toggle {
|
| 10 |
+
width: 100%;
|
| 11 |
+
display: flex;
|
| 12 |
+
align-items: center;
|
| 13 |
+
gap: 0.5rem;
|
| 14 |
+
padding: 0.75rem 1rem;
|
| 15 |
+
background: none;
|
| 16 |
+
color: var(--text-primary);
|
| 17 |
+
font-size: 0.85rem;
|
| 18 |
+
font-weight: 600;
|
| 19 |
+
text-align: left;
|
| 20 |
+
transition: background var(--transition-fast);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
.filter-toggle:hover {
|
| 24 |
+
background: rgba(255, 255, 255, 0.02);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
.filter-active-dot {
|
| 28 |
+
width: 6px;
|
| 29 |
+
height: 6px;
|
| 30 |
+
border-radius: 50%;
|
| 31 |
+
background: var(--accent-primary);
|
| 32 |
+
box-shadow: 0 0 6px var(--accent-primary);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
.filter-chevron {
|
| 36 |
+
margin-left: auto;
|
| 37 |
+
transition: transform var(--transition-fast);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.filter-chevron.open {
|
| 41 |
+
transform: rotate(180deg);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.filter-body {
|
| 45 |
+
padding: 0 1rem 1rem;
|
| 46 |
+
display: flex;
|
| 47 |
+
flex-direction: column;
|
| 48 |
+
gap: 1rem;
|
| 49 |
+
animation: fadeInUp 200ms ease;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.filter-group {
|
| 53 |
+
display: flex;
|
| 54 |
+
flex-direction: column;
|
| 55 |
+
gap: 0.4rem;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.home-headline {
|
| 59 |
+
font-size: 2.5rem;
|
| 60 |
+
font-weight: 800;
|
| 61 |
+
line-height: 1.15;
|
| 62 |
+
margin-bottom: 1.5rem;
|
| 63 |
+
color: var(--text-primary);
|
| 64 |
+
text-shadow: 4px 4px 0px rgba(0, 0, 0, 0.3);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.filter-label {
|
| 68 |
+
font-size: 0.68rem;
|
| 69 |
+
font-weight: 600;
|
| 70 |
+
text-transform: uppercase;
|
| 71 |
+
letter-spacing: 0.08em;
|
| 72 |
+
color: var(--text-muted);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.filter-chips {
|
| 76 |
+
display: flex;
|
| 77 |
+
flex-wrap: wrap;
|
| 78 |
+
gap: 0.35rem;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.filter-chip {
|
| 82 |
+
padding: 0.3rem 0.65rem;
|
| 83 |
+
border-radius: 20px;
|
| 84 |
+
font-size: 0.72rem;
|
| 85 |
+
font-weight: 500;
|
| 86 |
+
background: rgba(255, 255, 255, 0.04);
|
| 87 |
+
border: 1px solid var(--border-subtle);
|
| 88 |
+
color: var(--text-secondary);
|
| 89 |
+
transition: all var(--transition-fast);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.filter-chip:hover {
|
| 93 |
+
background: rgba(238, 105, 131, 0.08);
|
| 94 |
+
border-color: rgba(238, 105, 131, 0.2);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.filter-chip.active {
|
| 98 |
+
background: rgba(238, 105, 131, 0.2);
|
| 99 |
+
border-color: var(--accent-primary);
|
| 100 |
+
color: var(--accent-secondary);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.filter-budget-row {
|
| 104 |
+
display: flex;
|
| 105 |
+
align-items: center;
|
| 106 |
+
gap: 0.75rem;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.filter-slider {
|
| 110 |
+
flex: 1;
|
| 111 |
+
-webkit-appearance: none;
|
| 112 |
+
appearance: none;
|
| 113 |
+
height: 4px;
|
| 114 |
+
border-radius: 2px;
|
| 115 |
+
background: var(--border-subtle);
|
| 116 |
+
outline: none;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.filter-slider::-webkit-slider-thumb {
|
| 120 |
+
-webkit-appearance: none;
|
| 121 |
+
appearance: none;
|
| 122 |
+
width: 16px;
|
| 123 |
+
height: 16px;
|
| 124 |
+
border-radius: 50%;
|
| 125 |
+
background: var(--accent-primary);
|
| 126 |
+
cursor: pointer;
|
| 127 |
+
box-shadow: 0 0 6px rgba(238, 105, 131, 0.4);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.filter-budget-val {
|
| 131 |
+
font-size: 0.8rem;
|
| 132 |
+
font-weight: 600;
|
| 133 |
+
color: var(--accent-secondary);
|
| 134 |
+
min-width: 50px;
|
| 135 |
+
text-align: right;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.filter-select {
|
| 139 |
+
padding: 0.4rem 0.6rem;
|
| 140 |
+
background: var(--bg-input);
|
| 141 |
+
border: 1px solid var(--border-subtle);
|
| 142 |
+
border-radius: var(--radius-sm);
|
| 143 |
+
color: var(--text-primary);
|
| 144 |
+
font-size: 0.8rem;
|
| 145 |
+
cursor: pointer;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.filter-select:focus {
|
| 149 |
+
border-color: var(--border-focus);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.filter-select option {
|
| 153 |
+
background: #850E35;
|
| 154 |
+
color: var(--text-primary);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.filter-footer {
|
| 158 |
+
display: flex;
|
| 159 |
+
align-items: center;
|
| 160 |
+
justify-content: space-between;
|
| 161 |
+
padding-top: 0.5rem;
|
| 162 |
+
border-top: 1px solid var(--border-subtle);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
.filter-count {
|
| 166 |
+
font-size: 0.72rem;
|
| 167 |
+
color: var(--text-muted);
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.filter-reset {
|
| 171 |
+
font-size: 0.72rem;
|
| 172 |
+
font-weight: 500;
|
| 173 |
+
background: none;
|
| 174 |
+
color: var(--accent-secondary);
|
| 175 |
+
padding: 0.25rem 0.5rem;
|
| 176 |
+
border-radius: var(--radius-sm);
|
| 177 |
+
transition: background var(--transition-fast);
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
.filter-reset:hover {
|
| 181 |
+
background: rgba(238, 105, 131, 0.1);
|
| 182 |
+
}
|
client/src/components/FilterPanel.jsx
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import './FilterPanel.css';
|
| 3 |
+
|
| 4 |
+
export default function FilterPanel({ filters, onChange, dynamicFilters = [], resultCount }) {
|
| 5 |
+
const [expanded, setExpanded] = useState(true);
|
| 6 |
+
|
| 7 |
+
if (!dynamicFilters || dynamicFilters.length === 0) return null;
|
| 8 |
+
|
| 9 |
+
function updateFilter(key, value) {
|
| 10 |
+
onChange({ ...filters, [key]: value });
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function toggleSelectVal(id, option) {
|
| 14 |
+
const current = filters[id] || [];
|
| 15 |
+
const next = current.includes(option)
|
| 16 |
+
? current.filter(f => f !== option)
|
| 17 |
+
: [...current, option];
|
| 18 |
+
updateFilter(id, next);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function resetFilters() {
|
| 22 |
+
onChange({});
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const hasActiveFilters = Object.values(filters).some(val =>
|
| 26 |
+
(Array.isArray(val) && val.length > 0) ||
|
| 27 |
+
(typeof val === 'string' && val !== '' && val !== 'relevance')
|
| 28 |
+
);
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<aside className={`filter-panel ${expanded ? 'expanded' : 'collapsed'}`}>
|
| 32 |
+
<button className="filter-toggle" onClick={() => setExpanded(!expanded)}>
|
| 33 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 34 |
+
<line x1="4" y1="6" x2="20" y2="6" /><line x1="4" y1="12" x2="14" y2="12" /><line x1="4" y1="18" x2="8" y2="18" />
|
| 35 |
+
</svg>
|
| 36 |
+
AI Filters
|
| 37 |
+
{hasActiveFilters && <span className="filter-active-dot"></span>}
|
| 38 |
+
<svg className={`filter-chevron ${expanded ? 'open' : ''}`} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 39 |
+
<polyline points="6 9 12 15 18 9" />
|
| 40 |
+
</svg>
|
| 41 |
+
</button>
|
| 42 |
+
|
| 43 |
+
{expanded && (
|
| 44 |
+
<div className="filter-body">
|
| 45 |
+
{dynamicFilters.map((schema, index) => {
|
| 46 |
+
if (schema.type === 'range') {
|
| 47 |
+
return (
|
| 48 |
+
<div key={index} className="filter-group">
|
| 49 |
+
<label className="filter-label">{schema.label}</label>
|
| 50 |
+
<div className="filter-budget-row">
|
| 51 |
+
<input
|
| 52 |
+
type="range"
|
| 53 |
+
min={schema.min || 0}
|
| 54 |
+
max={schema.max || 5000}
|
| 55 |
+
step={schema.step || 100}
|
| 56 |
+
value={filters[schema.id] || 0}
|
| 57 |
+
onChange={e => updateFilter(schema.id, e.target.value === '0' ? '' : e.target.value)}
|
| 58 |
+
className="filter-slider"
|
| 59 |
+
/>
|
| 60 |
+
<span className="filter-budget-val">
|
| 61 |
+
{filters[schema.id] ? `₹${filters[schema.id]}` : 'Any'}
|
| 62 |
+
</span>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
if (schema.type === 'select') {
|
| 69 |
+
const activeSet = filters[schema.id] || [];
|
| 70 |
+
return (
|
| 71 |
+
<div key={index} className="filter-group">
|
| 72 |
+
<label className="filter-label">{schema.label}</label>
|
| 73 |
+
<div className="filter-chips">
|
| 74 |
+
{(schema.options || []).map(opt => (
|
| 75 |
+
<button
|
| 76 |
+
key={opt}
|
| 77 |
+
className={`filter-chip ${activeSet.includes(opt) ? 'active' : ''}`}
|
| 78 |
+
onClick={() => toggleSelectVal(schema.id, opt)}
|
| 79 |
+
>
|
| 80 |
+
{opt}
|
| 81 |
+
</button>
|
| 82 |
+
))}
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
if (schema.type === 'sort') {
|
| 89 |
+
return (
|
| 90 |
+
<div key={index} className="filter-group">
|
| 91 |
+
<label className="filter-label">{schema.label}</label>
|
| 92 |
+
<select
|
| 93 |
+
className="filter-select"
|
| 94 |
+
value={filters[schema.id] || 'relevance'}
|
| 95 |
+
onChange={e => updateFilter(schema.id, e.target.value)}
|
| 96 |
+
>
|
| 97 |
+
{(schema.options || []).map(opt => (
|
| 98 |
+
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
| 99 |
+
))}
|
| 100 |
+
</select>
|
| 101 |
+
</div>
|
| 102 |
+
);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
return null;
|
| 106 |
+
})}
|
| 107 |
+
|
| 108 |
+
<div className="filter-footer">
|
| 109 |
+
{resultCount !== undefined && (
|
| 110 |
+
<span className="filter-count">{resultCount} result{resultCount !== 1 ? 's' : ''}</span>
|
| 111 |
+
)}
|
| 112 |
+
{hasActiveFilters && (
|
| 113 |
+
<button className="filter-reset" onClick={resetFilters}>Reset Custom AI Filters</button>
|
| 114 |
+
)}
|
| 115 |
+
</div>
|
| 116 |
+
</div>
|
| 117 |
+
)}
|
| 118 |
+
</aside>
|
| 119 |
+
);
|
| 120 |
+
}
|
client/src/components/Header.css
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.header {
|
| 2 |
+
position: sticky;
|
| 3 |
+
top: 0;
|
| 4 |
+
z-index: 100;
|
| 5 |
+
background: rgba(133, 14, 53, 0.85);
|
| 6 |
+
backdrop-filter: blur(16px);
|
| 7 |
+
border-bottom: 1px solid var(--border-subtle);
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
.header-inner {
|
| 11 |
+
max-width: 1200px;
|
| 12 |
+
margin: 0 auto;
|
| 13 |
+
padding: 0.75rem 1.5rem;
|
| 14 |
+
display: flex;
|
| 15 |
+
align-items: center;
|
| 16 |
+
justify-content: space-between;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
.header-brand {
|
| 20 |
+
display: flex;
|
| 21 |
+
align-items: center;
|
| 22 |
+
gap: 0.625rem;
|
| 23 |
+
text-decoration: none;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.header-logo {
|
| 27 |
+
display: flex;
|
| 28 |
+
align-items: center;
|
| 29 |
+
position: relative;
|
| 30 |
+
width: 32px;
|
| 31 |
+
height: 32px;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.thread-logo {
|
| 35 |
+
width: 100%;
|
| 36 |
+
height: 100%;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.thread-path-outer {
|
| 40 |
+
stroke: var(--accent-primary);
|
| 41 |
+
stroke-width: 1.5;
|
| 42 |
+
stroke-linecap: round;
|
| 43 |
+
stroke-dasharray: 80;
|
| 44 |
+
stroke-dashoffset: 80;
|
| 45 |
+
animation: thread-unfold 4s ease-in-out infinite alternate;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.thread-core {
|
| 49 |
+
fill: var(--text-primary);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.thread-line {
|
| 53 |
+
stroke: var(--accent-secondary);
|
| 54 |
+
stroke-width: 1;
|
| 55 |
+
stroke-dasharray: 28;
|
| 56 |
+
stroke-dashoffset: 28;
|
| 57 |
+
animation: thread-line-flow 4s linear infinite;
|
| 58 |
+
opacity: 0.6;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
@keyframes thread-unfold {
|
| 62 |
+
0% {
|
| 63 |
+
stroke-dashoffset: 80;
|
| 64 |
+
opacity: 0.3;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
50% {
|
| 68 |
+
stroke-dashoffset: 0;
|
| 69 |
+
opacity: 1;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
100% {
|
| 73 |
+
stroke-dashoffset: -80;
|
| 74 |
+
opacity: 0.3;
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
@keyframes core-pulse {
|
| 79 |
+
0% {
|
| 80 |
+
transform: scale(0.8);
|
| 81 |
+
opacity: 0.6;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
100% {
|
| 85 |
+
transform: scale(1.2);
|
| 86 |
+
opacity: 1;
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
@keyframes thread-line-flow {
|
| 91 |
+
0% {
|
| 92 |
+
stroke-dashoffset: 28;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
100% {
|
| 96 |
+
stroke-dashoffset: -28;
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.header-title {
|
| 101 |
+
font-size: 1.25rem;
|
| 102 |
+
font-weight: 700;
|
| 103 |
+
background: var(--accent-gradient);
|
| 104 |
+
-webkit-background-clip: text;
|
| 105 |
+
-webkit-text-fill-color: transparent;
|
| 106 |
+
background-clip: text;
|
| 107 |
+
letter-spacing: -0.02em;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.header-badge {
|
| 111 |
+
font-size: 0.6rem;
|
| 112 |
+
font-weight: 700;
|
| 113 |
+
text-transform: uppercase;
|
| 114 |
+
letter-spacing: 0.1em;
|
| 115 |
+
padding: 0.125rem 0.4rem;
|
| 116 |
+
border-radius: 4px;
|
| 117 |
+
background: var(--accent-gradient);
|
| 118 |
+
color: white;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.header-nav {
|
| 122 |
+
display: flex;
|
| 123 |
+
align-items: center;
|
| 124 |
+
gap: 0.25rem;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.header-link {
|
| 128 |
+
display: flex;
|
| 129 |
+
align-items: center;
|
| 130 |
+
gap: 0.35rem;
|
| 131 |
+
padding: 0.4rem 0.75rem;
|
| 132 |
+
border-radius: var(--radius-sm);
|
| 133 |
+
font-size: 0.8rem;
|
| 134 |
+
font-weight: 500;
|
| 135 |
+
color: var(--text-muted);
|
| 136 |
+
text-decoration: none;
|
| 137 |
+
transition: all var(--transition-fast);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.header-link:hover {
|
| 141 |
+
color: var(--text-secondary);
|
| 142 |
+
background: rgba(255, 255, 255, 0.04);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.header-link.active {
|
| 146 |
+
color: var(--accent-secondary);
|
| 147 |
+
background: rgba(238, 105, 131, 0.1);
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.header-status {
|
| 151 |
+
display: flex;
|
| 152 |
+
align-items: center;
|
| 153 |
+
gap: 0.4rem;
|
| 154 |
+
font-size: 0.7rem;
|
| 155 |
+
color: var(--text-muted);
|
| 156 |
+
margin-left: 0.75rem;
|
| 157 |
+
padding-left: 0.75rem;
|
| 158 |
+
border-left: 1px solid var(--border-subtle);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.status-dot {
|
| 162 |
+
width: 6px;
|
| 163 |
+
height: 6px;
|
| 164 |
+
border-radius: 50%;
|
| 165 |
+
background: var(--success);
|
| 166 |
+
box-shadow: 0 0 8px var(--success);
|
| 167 |
+
animation: pulse-glow-green 2s infinite;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
@keyframes pulse-glow-green {
|
| 171 |
+
|
| 172 |
+
0%,
|
| 173 |
+
100% {
|
| 174 |
+
box-shadow: 0 0 4px var(--success);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
50% {
|
| 178 |
+
box-shadow: 0 0 12px var(--success);
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
@media (max-width: 600px) {
|
| 183 |
+
.header-nav {
|
| 184 |
+
gap: 0;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.header-link span,
|
| 188 |
+
.header-link svg+* {
|
| 189 |
+
display: none;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.header-link {
|
| 193 |
+
padding: 0.4rem;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.header-status {
|
| 197 |
+
display: none;
|
| 198 |
+
}
|
| 199 |
+
}
|
client/src/components/Header.jsx
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import Link from 'next/link';
|
| 4 |
+
import { usePathname } from 'next/navigation';
|
| 5 |
+
import './Header.css';
|
| 6 |
+
|
| 7 |
+
export default function Header() {
|
| 8 |
+
const pathname = usePathname();
|
| 9 |
+
|
| 10 |
+
const isActive = (path) => pathname === path;
|
| 11 |
+
|
| 12 |
+
return (
|
| 13 |
+
<header className="header">
|
| 14 |
+
<div className="header-inner">
|
| 15 |
+
<Link href="/" className="header-brand">
|
| 16 |
+
<div className="header-logo">
|
| 17 |
+
<div className="header-logo">
|
| 18 |
+
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" className="thread-logo">
|
| 19 |
+
<path d="M4 16C4 16 8 8 16 8C24 8 28 16 28 16C28 16 24 24 16 24C8 24 4 16 4 16Z" className="thread-path-outer" />
|
| 20 |
+
<circle cx="16" cy="16" r="3" className="thread-core" />
|
| 21 |
+
<path d="M2 16H30" className="thread-line" />
|
| 22 |
+
</svg>
|
| 23 |
+
</div>
|
| 24 |
+
</div>
|
| 25 |
+
<h1 className="header-title">RedThread</h1>
|
| 26 |
+
<span className="header-badge">AI</span>
|
| 27 |
+
</Link>
|
| 28 |
+
|
| 29 |
+
<nav className="header-nav">
|
| 30 |
+
<Link href="/" className={`header-link ${isActive('/') ? 'active' : ''}`}>
|
| 31 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 32 |
+
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
| 33 |
+
</svg>
|
| 34 |
+
Search
|
| 35 |
+
</Link>
|
| 36 |
+
<Link href="/history" className={`header-link ${isActive('/history') ? 'active' : ''}`}>
|
| 37 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 38 |
+
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
| 39 |
+
</svg>
|
| 40 |
+
History
|
| 41 |
+
</Link>
|
| 42 |
+
<Link href="/about" className={`header-link ${isActive('/about') ? 'active' : ''}`}>
|
| 43 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 44 |
+
<circle cx="12" cy="12" r="10" /><line x1="12" y1="16" x2="12" y2="12" /><line x1="12" y1="8" x2="12.01" y2="8" />
|
| 45 |
+
</svg>
|
| 46 |
+
About
|
| 47 |
+
</Link>
|
| 48 |
+
<span className="header-status">
|
| 49 |
+
<span className="status-dot"></span>
|
| 50 |
+
Online
|
| 51 |
+
</span>
|
| 52 |
+
</nav>
|
| 53 |
+
</div>
|
| 54 |
+
</header>
|
| 55 |
+
);
|
| 56 |
+
}
|
client/src/components/Providers.jsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { ToastProvider } from '../contexts/ToastContext';
|
| 4 |
+
|
| 5 |
+
export function Providers({ children }) {
|
| 6 |
+
return (
|
| 7 |
+
<ToastProvider>
|
| 8 |
+
{children}
|
| 9 |
+
</ToastProvider>
|
| 10 |
+
);
|
| 11 |
+
}
|
client/src/components/ResultCard.css
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.result-card {
|
| 2 |
+
background: var(--bg-card);
|
| 3 |
+
border: 1px solid var(--border-subtle);
|
| 4 |
+
border-radius: var(--radius-md);
|
| 5 |
+
padding: 1rem;
|
| 6 |
+
display: flex;
|
| 7 |
+
flex-direction: column;
|
| 8 |
+
gap: 0.75rem;
|
| 9 |
+
cursor: pointer;
|
| 10 |
+
transition: all var(--transition-normal);
|
| 11 |
+
position: relative;
|
| 12 |
+
overflow: hidden;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
.result-card::before {
|
| 16 |
+
content: '';
|
| 17 |
+
position: absolute;
|
| 18 |
+
inset: 0;
|
| 19 |
+
background: var(--accent-gradient);
|
| 20 |
+
opacity: 0;
|
| 21 |
+
transition: opacity var(--transition-normal);
|
| 22 |
+
z-index: 0;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
.result-card:hover {
|
| 26 |
+
border-color: var(--border-hover);
|
| 27 |
+
box-shadow: var(--shadow-md);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
.result-card:hover::before {
|
| 31 |
+
opacity: 0.03;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.result-card>* {
|
| 35 |
+
position: relative;
|
| 36 |
+
z-index: 1;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.result-card-header {
|
| 40 |
+
display: flex;
|
| 41 |
+
justify-content: space-between;
|
| 42 |
+
align-items: flex-start;
|
| 43 |
+
gap: 1rem;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
.result-card-title-row {
|
| 47 |
+
display: flex;
|
| 48 |
+
flex-direction: column;
|
| 49 |
+
gap: 0.25rem;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.result-card-name {
|
| 53 |
+
font-size: 1.15rem;
|
| 54 |
+
font-weight: 700;
|
| 55 |
+
color: var(--text-primary);
|
| 56 |
+
line-height: 1.2;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.result-card-category {
|
| 60 |
+
font-size: 0.7rem;
|
| 61 |
+
font-weight: 600;
|
| 62 |
+
text-transform: uppercase;
|
| 63 |
+
letter-spacing: 0.05em;
|
| 64 |
+
color: var(--accent-primary);
|
| 65 |
+
background: rgba(238, 105, 131, 0.1);
|
| 66 |
+
padding: 0.2rem 0.5rem;
|
| 67 |
+
border-radius: 4px;
|
| 68 |
+
width: fit-content;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.result-card-rating {
|
| 72 |
+
display: flex;
|
| 73 |
+
align-items: center;
|
| 74 |
+
gap: 0.25rem;
|
| 75 |
+
font-weight: 700;
|
| 76 |
+
color: var(--text-primary);
|
| 77 |
+
background: var(--bg-input);
|
| 78 |
+
padding: 0.25rem 0.5rem;
|
| 79 |
+
border-radius: var(--radius-sm);
|
| 80 |
+
font-size: 0.85rem;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.result-card-address {
|
| 84 |
+
font-size: 0.85rem;
|
| 85 |
+
color: var(--text-secondary);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.result-card-price {
|
| 89 |
+
font-size: 0.9rem;
|
| 90 |
+
font-weight: 600;
|
| 91 |
+
color: var(--success);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.result-card-features {
|
| 95 |
+
display: flex;
|
| 96 |
+
flex-wrap: wrap;
|
| 97 |
+
gap: 0.4rem;
|
| 98 |
+
margin-top: 0.25rem;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.result-card-tag {
|
| 102 |
+
font-size: 0.75rem;
|
| 103 |
+
padding: 0.2rem 0.5rem;
|
| 104 |
+
background: var(--bg-secondary);
|
| 105 |
+
border: 1px solid var(--border-subtle);
|
| 106 |
+
border-radius: var(--radius-sm);
|
| 107 |
+
color: var(--text-secondary);
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.result-card-review {
|
| 111 |
+
font-style: italic;
|
| 112 |
+
font-size: 0.85rem;
|
| 113 |
+
color: var(--text-muted);
|
| 114 |
+
margin-top: 0.5rem;
|
| 115 |
+
padding-left: 0.75rem;
|
| 116 |
+
border-left: 2px solid var(--border-hover);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.result-card-footer {
|
| 120 |
+
margin-top: auto;
|
| 121 |
+
padding-top: 1rem;
|
| 122 |
+
display: flex;
|
| 123 |
+
justify-content: space-between;
|
| 124 |
+
align-items: center;
|
| 125 |
+
font-size: 0.75rem;
|
| 126 |
+
border-top: 1px solid var(--border-subtle);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.result-card-source {
|
| 130 |
+
color: var(--text-muted);
|
| 131 |
+
text-transform: uppercase;
|
| 132 |
+
letter-spacing: 0.05em;
|
| 133 |
+
display: flex;
|
| 134 |
+
align-items: center;
|
| 135 |
+
gap: 0.5rem;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.cache-badge {
|
| 139 |
+
background: rgba(46, 204, 113, 0.15);
|
| 140 |
+
color: var(--success);
|
| 141 |
+
padding: 0.1rem 0.4rem;
|
| 142 |
+
border-radius: 4px;
|
| 143 |
+
font-weight: 700;
|
| 144 |
+
font-size: 0.65rem;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.result-card-expand {
|
| 148 |
+
color: var(--accent-secondary);
|
| 149 |
+
font-weight: 600;
|
| 150 |
+
opacity: 0;
|
| 151 |
+
transform: translateX(-10px);
|
| 152 |
+
transition: all var(--transition-normal);
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.result-card:hover .result-card-expand {
|
| 156 |
+
opacity: 1;
|
| 157 |
+
transform: translateX(0);
|
| 158 |
+
}
|
client/src/components/ResultCard.jsx
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import './ResultCard.css';
|
| 2 |
+
|
| 3 |
+
export default function ResultCard({ result, index, onClick }) {
|
| 4 |
+
return (
|
| 5 |
+
<article
|
| 6 |
+
className={`result-card fade-in-up stagger-${(index % 4) + 1}`}
|
| 7 |
+
onClick={onClick}
|
| 8 |
+
role="button"
|
| 9 |
+
tabIndex={0}
|
| 10 |
+
onKeyDown={e => { if (e.key === 'Enter') onClick?.(); }}
|
| 11 |
+
>
|
| 12 |
+
<div className="result-card-header">
|
| 13 |
+
<div className="result-card-title-row">
|
| 14 |
+
<h3 className="result-card-name">{result.name}</h3>
|
| 15 |
+
<span className="result-card-category">{result.category}</span>
|
| 16 |
+
</div>
|
| 17 |
+
<div className="result-card-rating">
|
| 18 |
+
{result.rating !== 'N/A' && (
|
| 19 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="#fdcb6e" stroke="none">
|
| 20 |
+
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
| 21 |
+
</svg>
|
| 22 |
+
)}
|
| 23 |
+
<span>{result.rating}</span>
|
| 24 |
+
</div>
|
| 25 |
+
</div>
|
| 26 |
+
|
| 27 |
+
<p className="result-card-address">{result.address}</p>
|
| 28 |
+
{result.priceRange && result.priceRange !== 'N/A' && (
|
| 29 |
+
<p className="result-card-price">{result.priceRange}</p>
|
| 30 |
+
)}
|
| 31 |
+
|
| 32 |
+
{result.features && result.features.length > 0 && (
|
| 33 |
+
<div className="result-card-features">
|
| 34 |
+
{result.features.map((f, i) => (
|
| 35 |
+
<span key={i} className="result-card-tag">{f}</span>
|
| 36 |
+
))}
|
| 37 |
+
</div>
|
| 38 |
+
)}
|
| 39 |
+
|
| 40 |
+
{result.reviewSummary && (
|
| 41 |
+
<p className="result-card-review">"{result.reviewSummary}"</p>
|
| 42 |
+
)}
|
| 43 |
+
|
| 44 |
+
<div className="result-card-footer">
|
| 45 |
+
<span className="result-card-source">
|
| 46 |
+
Source: {result.source}
|
| 47 |
+
{result.cached && <span className="cache-badge">⚡ Cached</span>}
|
| 48 |
+
</span>
|
| 49 |
+
<span className="result-card-expand">View Details →</span>
|
| 50 |
+
</div>
|
| 51 |
+
</article>
|
| 52 |
+
);
|
| 53 |
+
}
|
client/src/components/ResultModal.css
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.modal-overlay {
|
| 2 |
+
position: fixed;
|
| 3 |
+
inset: 0;
|
| 4 |
+
z-index: 200;
|
| 5 |
+
background: rgba(0, 0, 0, 0.65);
|
| 6 |
+
backdrop-filter: blur(4px);
|
| 7 |
+
display: flex;
|
| 8 |
+
align-items: center;
|
| 9 |
+
justify-content: center;
|
| 10 |
+
padding: 1.5rem;
|
| 11 |
+
animation: fadeInUp 200ms ease;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
.modal-content {
|
| 15 |
+
position: relative;
|
| 16 |
+
width: 100%;
|
| 17 |
+
max-width: 560px;
|
| 18 |
+
max-height: 85vh;
|
| 19 |
+
overflow-y: auto;
|
| 20 |
+
background: var(--bg-secondary);
|
| 21 |
+
border: 1px solid var(--border-subtle);
|
| 22 |
+
border-radius: var(--radius-xl);
|
| 23 |
+
padding: 2rem;
|
| 24 |
+
box-shadow: var(--shadow-lg);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
.modal-close {
|
| 28 |
+
position: absolute;
|
| 29 |
+
top: 1rem;
|
| 30 |
+
right: 1rem;
|
| 31 |
+
width: 32px;
|
| 32 |
+
height: 32px;
|
| 33 |
+
display: flex;
|
| 34 |
+
align-items: center;
|
| 35 |
+
justify-content: center;
|
| 36 |
+
border-radius: 50%;
|
| 37 |
+
background: rgba(255, 255, 255, 0.05);
|
| 38 |
+
color: var(--text-muted);
|
| 39 |
+
transition: all var(--transition-fast);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
.modal-close:hover {
|
| 43 |
+
background: rgba(255, 255, 255, 0.1);
|
| 44 |
+
color: var(--text-primary);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.modal-header {
|
| 48 |
+
display: flex;
|
| 49 |
+
justify-content: space-between;
|
| 50 |
+
align-items: flex-start;
|
| 51 |
+
margin-bottom: 1.25rem;
|
| 52 |
+
padding-right: 2.5rem;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.modal-name {
|
| 56 |
+
font-size: 1.3rem;
|
| 57 |
+
font-weight: 700;
|
| 58 |
+
margin-bottom: 0.3rem;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.modal-category {
|
| 62 |
+
font-size: 0.65rem;
|
| 63 |
+
font-weight: 600;
|
| 64 |
+
text-transform: uppercase;
|
| 65 |
+
letter-spacing: 0.08em;
|
| 66 |
+
padding: 0.15rem 0.5rem;
|
| 67 |
+
border-radius: 4px;
|
| 68 |
+
background: rgba(238, 105, 131, 0.15);
|
| 69 |
+
color: var(--accent-secondary);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.modal-rating {
|
| 73 |
+
display: flex;
|
| 74 |
+
align-items: center;
|
| 75 |
+
gap: 0.35rem;
|
| 76 |
+
font-size: 1.1rem;
|
| 77 |
+
font-weight: 700;
|
| 78 |
+
color: var(--warning);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.modal-detail-grid {
|
| 82 |
+
display: grid;
|
| 83 |
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
| 84 |
+
gap: 0.75rem;
|
| 85 |
+
margin-bottom: 1.25rem;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.modal-detail {
|
| 89 |
+
padding: 0.6rem 0.75rem;
|
| 90 |
+
background: var(--bg-card);
|
| 91 |
+
border-radius: var(--radius-sm);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.modal-detail-label {
|
| 95 |
+
display: block;
|
| 96 |
+
font-size: 0.62rem;
|
| 97 |
+
font-weight: 600;
|
| 98 |
+
text-transform: uppercase;
|
| 99 |
+
letter-spacing: 0.08em;
|
| 100 |
+
color: var(--text-muted);
|
| 101 |
+
margin-bottom: 0.2rem;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.modal-detail span:last-child {
|
| 105 |
+
font-size: 0.85rem;
|
| 106 |
+
color: var(--text-primary);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.modal-price {
|
| 110 |
+
color: var(--success) !important;
|
| 111 |
+
font-weight: 600;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
.modal-section {
|
| 115 |
+
margin-bottom: 1.25rem;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.modal-section-title {
|
| 119 |
+
font-size: 0.7rem;
|
| 120 |
+
font-weight: 600;
|
| 121 |
+
text-transform: uppercase;
|
| 122 |
+
letter-spacing: 0.08em;
|
| 123 |
+
color: var(--text-muted);
|
| 124 |
+
margin-bottom: 0.5rem;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.modal-features {
|
| 128 |
+
display: flex;
|
| 129 |
+
flex-wrap: wrap;
|
| 130 |
+
gap: 0.4rem;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.modal-feature-tag {
|
| 134 |
+
display: flex;
|
| 135 |
+
align-items: center;
|
| 136 |
+
gap: 0.3rem;
|
| 137 |
+
font-size: 0.78rem;
|
| 138 |
+
font-weight: 500;
|
| 139 |
+
padding: 0.25rem 0.6rem;
|
| 140 |
+
border-radius: 20px;
|
| 141 |
+
background: rgba(0, 184, 148, 0.1);
|
| 142 |
+
border: 1px solid rgba(0, 184, 148, 0.2);
|
| 143 |
+
color: var(--success);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.modal-review {
|
| 147 |
+
font-size: 0.88rem;
|
| 148 |
+
color: var(--text-secondary);
|
| 149 |
+
font-style: italic;
|
| 150 |
+
line-height: 1.6;
|
| 151 |
+
padding: 0.75rem 1rem;
|
| 152 |
+
border-left: 3px solid var(--accent-primary);
|
| 153 |
+
background: rgba(238, 105, 131, 0.05);
|
| 154 |
+
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
| 155 |
+
margin: 0;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
.modal-map-button {
|
| 159 |
+
display: flex;
|
| 160 |
+
align-items: center;
|
| 161 |
+
justify-content: center;
|
| 162 |
+
gap: 0.5rem;
|
| 163 |
+
padding: 0.8rem 1.5rem;
|
| 164 |
+
background: var(--accent-primary);
|
| 165 |
+
color: white;
|
| 166 |
+
font-size: 0.9rem;
|
| 167 |
+
font-weight: 600;
|
| 168 |
+
text-decoration: none;
|
| 169 |
+
border-radius: var(--radius-md);
|
| 170 |
+
transition: all var(--transition-fast);
|
| 171 |
+
box-shadow: 0 4px 15px rgba(238, 105, 131, 0.2);
|
| 172 |
+
margin-top: 1rem;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.modal-map-button:hover {
|
| 176 |
+
background: var(--accent-secondary);
|
| 177 |
+
transform: translateY(-2px);
|
| 178 |
+
box-shadow: 0 6px 20px rgba(238, 105, 131, 0.3);
|
| 179 |
+
}
|
client/src/components/ResultModal.jsx
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect } from 'react';
|
| 2 |
+
import './ResultModal.css';
|
| 3 |
+
|
| 4 |
+
export default function ResultModal({ result, onClose }) {
|
| 5 |
+
useEffect(() => {
|
| 6 |
+
function handleKey(e) {
|
| 7 |
+
if (e.key === 'Escape') onClose();
|
| 8 |
+
}
|
| 9 |
+
document.addEventListener('keydown', handleKey);
|
| 10 |
+
document.body.style.overflow = 'hidden';
|
| 11 |
+
return () => {
|
| 12 |
+
document.removeEventListener('keydown', handleKey);
|
| 13 |
+
document.body.style.overflow = '';
|
| 14 |
+
};
|
| 15 |
+
}, [onClose]);
|
| 16 |
+
|
| 17 |
+
if (!result) return null;
|
| 18 |
+
|
| 19 |
+
return (
|
| 20 |
+
<div className="modal-overlay" onClick={onClose}>
|
| 21 |
+
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
| 22 |
+
<button className="modal-close" onClick={onClose}>
|
| 23 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 24 |
+
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
| 25 |
+
</svg>
|
| 26 |
+
</button>
|
| 27 |
+
|
| 28 |
+
<div className="modal-header">
|
| 29 |
+
<div>
|
| 30 |
+
<h2 className="modal-name">{result.name}</h2>
|
| 31 |
+
<span className="modal-category">{result.category}</span>
|
| 32 |
+
</div>
|
| 33 |
+
<div className="modal-rating">
|
| 34 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="#fdcb6e" stroke="none">
|
| 35 |
+
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
| 36 |
+
</svg>
|
| 37 |
+
<span>{result.rating}</span>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<div className="modal-detail-grid">
|
| 42 |
+
<div className="modal-detail">
|
| 43 |
+
<span className="modal-detail-label">Address</span>
|
| 44 |
+
<span>{result.address}</span>
|
| 45 |
+
</div>
|
| 46 |
+
{result.priceRange && result.priceRange !== 'N/A' && (
|
| 47 |
+
<div className="modal-detail">
|
| 48 |
+
<span className="modal-detail-label">Price Range</span>
|
| 49 |
+
<span className="modal-price">{result.priceRange}</span>
|
| 50 |
+
</div>
|
| 51 |
+
)}
|
| 52 |
+
<div className="modal-detail">
|
| 53 |
+
<span className="modal-detail-label">Data Source</span>
|
| 54 |
+
<span>{result.source}</span>
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
{result.features && result.features.length > 0 && (
|
| 59 |
+
<div className="modal-section">
|
| 60 |
+
<h4 className="modal-section-title">Features</h4>
|
| 61 |
+
<div className="modal-features">
|
| 62 |
+
{result.features.map((f, i) => (
|
| 63 |
+
<span key={i} className="modal-feature-tag">
|
| 64 |
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
| 65 |
+
<polyline points="20 6 9 17 4 12" />
|
| 66 |
+
</svg>
|
| 67 |
+
{f}
|
| 68 |
+
</span>
|
| 69 |
+
))}
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
)}
|
| 73 |
+
|
| 74 |
+
{result.reviewSummary && (
|
| 75 |
+
<div className="modal-section">
|
| 76 |
+
<h4 className="modal-section-title">AI Review Summary</h4>
|
| 77 |
+
<blockquote className="modal-review">
|
| 78 |
+
{result.reviewSummary}
|
| 79 |
+
</blockquote>
|
| 80 |
+
</div>
|
| 81 |
+
)}
|
| 82 |
+
|
| 83 |
+
<a
|
| 84 |
+
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${result.name} ${result.address}`)}`}
|
| 85 |
+
target="_blank"
|
| 86 |
+
rel="noopener noreferrer"
|
| 87 |
+
className="modal-map-button"
|
| 88 |
+
>
|
| 89 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 90 |
+
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
| 91 |
+
<circle cx="12" cy="10" r="3" />
|
| 92 |
+
</svg>
|
| 93 |
+
View on Google Maps
|
| 94 |
+
</a>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
);
|
| 98 |
+
}
|
client/src/components/SafetyBanner.css
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.safety-banner {
|
| 2 |
+
display: flex;
|
| 3 |
+
align-items: flex-start;
|
| 4 |
+
gap: 0.75rem;
|
| 5 |
+
max-width: 720px;
|
| 6 |
+
margin: 0 auto;
|
| 7 |
+
padding: 0.75rem 1rem;
|
| 8 |
+
background: rgba(238, 105, 131, 0.06);
|
| 9 |
+
border: 1px solid rgba(238, 105, 131, 0.12);
|
| 10 |
+
border-radius: var(--radius-md);
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
.safety-banner-icon {
|
| 14 |
+
flex-shrink: 0;
|
| 15 |
+
color: var(--accent-secondary);
|
| 16 |
+
margin-top: 0.1rem;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
.safety-banner-text {
|
| 20 |
+
font-size: 0.75rem;
|
| 21 |
+
color: var(--text-secondary);
|
| 22 |
+
line-height: 1.5;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
.safety-banner-text a {
|
| 26 |
+
color: var(--accent-secondary);
|
| 27 |
+
text-decoration: underline;
|
| 28 |
+
text-underline-offset: 2px;
|
| 29 |
+
}
|
client/src/components/SafetyBanner.jsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Link from 'next/link';
|
| 2 |
+
import './SafetyBanner.css';
|
| 3 |
+
|
| 4 |
+
export default function SafetyBanner() {
|
| 5 |
+
return (
|
| 6 |
+
<aside className="safety-banner">
|
| 7 |
+
<div className="safety-banner-icon">
|
| 8 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 9 |
+
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
| 10 |
+
</svg>
|
| 11 |
+
</div>
|
| 12 |
+
<div className="safety-banner-content">
|
| 13 |
+
<p className="safety-banner-text">
|
| 14 |
+
RedThread uses AI-powered safety validation. Harmful, illegal, or adult content queries are automatically blocked.
|
| 15 |
+
By searching, you agree to our <Link href="/terms">Terms of Use</Link> and accept responsibility for ethical use.
|
| 16 |
+
</p>
|
| 17 |
+
</div>
|
| 18 |
+
</aside>
|
| 19 |
+
);
|
| 20 |
+
}
|
client/src/components/SearchBar.css
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.searchbar-container {
|
| 2 |
+
max-width: 720px;
|
| 3 |
+
margin: 0 auto;
|
| 4 |
+
position: relative;
|
| 5 |
+
z-index: 100;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
.searchbar {
|
| 9 |
+
display: flex;
|
| 10 |
+
gap: 0.75rem;
|
| 11 |
+
position: relative;
|
| 12 |
+
z-index: 10;
|
| 13 |
+
/* Ensure searchbar is above siblings like location-active-plate */
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
.location-hint {
|
| 17 |
+
margin-top: 0.6rem;
|
| 18 |
+
margin-left: 0.15rem;
|
| 19 |
+
font-size: 0.8rem;
|
| 20 |
+
color: var(--text-secondary);
|
| 21 |
+
max-width: 720px;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.location-hint .mono {
|
| 25 |
+
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
|
| 26 |
+
font-size: 0.78rem;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
.searchbar-input-wrap {
|
| 30 |
+
flex: 1;
|
| 31 |
+
position: relative;
|
| 32 |
+
display: flex;
|
| 33 |
+
align-items: center;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.location-active-plate {
|
| 37 |
+
display: inline-flex;
|
| 38 |
+
align-items: center;
|
| 39 |
+
gap: 0.5rem;
|
| 40 |
+
align-self: flex-start;
|
| 41 |
+
margin-top: 0.75rem;
|
| 42 |
+
margin-left: 1rem;
|
| 43 |
+
padding: 0.4rem 0.8rem;
|
| 44 |
+
background: rgba(238, 105, 131, 0.1);
|
| 45 |
+
border: 1px solid rgba(238, 105, 131, 0.2);
|
| 46 |
+
border-radius: 20px;
|
| 47 |
+
font-size: 0.8rem;
|
| 48 |
+
color: var(--text-secondary);
|
| 49 |
+
animation: fadeIn 0.2s ease-out;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.active-dot {
|
| 53 |
+
width: 6px;
|
| 54 |
+
height: 6px;
|
| 55 |
+
background-color: var(--primary-color);
|
| 56 |
+
border-radius: 50%;
|
| 57 |
+
box-shadow: 0 0 8px var(--primary-color);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.clear-location-text {
|
| 61 |
+
background: none;
|
| 62 |
+
border: none;
|
| 63 |
+
color: var(--text-muted);
|
| 64 |
+
font-size: 0.75rem;
|
| 65 |
+
margin-left: 0.25rem;
|
| 66 |
+
cursor: pointer;
|
| 67 |
+
text-decoration: underline;
|
| 68 |
+
text-decoration-color: transparent;
|
| 69 |
+
transition: all 0.2s ease;
|
| 70 |
+
padding: 0;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.clear-location-text:hover {
|
| 74 |
+
color: #ff7675;
|
| 75 |
+
text-decoration-color: #ff7675;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
.location-btn {
|
| 81 |
+
position: absolute;
|
| 82 |
+
left: 10px;
|
| 83 |
+
background: transparent;
|
| 84 |
+
border: none;
|
| 85 |
+
color: var(--text-muted);
|
| 86 |
+
cursor: pointer;
|
| 87 |
+
display: flex;
|
| 88 |
+
align-items: center;
|
| 89 |
+
justify-content: center;
|
| 90 |
+
padding: 8px;
|
| 91 |
+
border-radius: 50%;
|
| 92 |
+
transition: all 0.2s ease;
|
| 93 |
+
z-index: 2;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.location-btn:hover:not(:disabled) {
|
| 97 |
+
background: rgba(255, 255, 255, 0.1);
|
| 98 |
+
color: var(--text-main);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.location-btn.active {
|
| 102 |
+
color: var(--primary-color);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.location-btn:not(.active):not(.error):not(:disabled) {
|
| 106 |
+
animation: pulse-hint 3s infinite;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
@keyframes pulse-hint {
|
| 110 |
+
0% {
|
| 111 |
+
transform: scale(1);
|
| 112 |
+
filter: brightness(1);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
50% {
|
| 116 |
+
transform: scale(1.15);
|
| 117 |
+
filter: brightness(1.3);
|
| 118 |
+
color: var(--accent-primary);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
100% {
|
| 122 |
+
transform: scale(1);
|
| 123 |
+
filter: brightness(1);
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.location-btn.error {
|
| 128 |
+
color: #ff7675;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.location-spinner {
|
| 132 |
+
width: 20px;
|
| 133 |
+
height: 20px;
|
| 134 |
+
border: 2px solid rgba(255, 255, 255, 0.2);
|
| 135 |
+
border-top-color: var(--primary-color);
|
| 136 |
+
border-radius: 50%;
|
| 137 |
+
animation: spin 1s linear infinite;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
/* Consolidated search input styling */
|
| 141 |
+
.searchbar-input {
|
| 142 |
+
width: 100%;
|
| 143 |
+
padding: 0.875rem 1rem 0.875rem 3rem;
|
| 144 |
+
background: var(--bg-input);
|
| 145 |
+
border: 1.5px solid var(--border-subtle);
|
| 146 |
+
border-radius: var(--radius-md);
|
| 147 |
+
color: var(--text-primary);
|
| 148 |
+
font-size: 0.95rem;
|
| 149 |
+
transition: all var(--transition-normal);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.searchbar-input::placeholder {
|
| 153 |
+
color: var(--text-muted);
|
| 154 |
+
font-size: 0.85rem;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.searchbar-input:focus {
|
| 158 |
+
border-color: var(--border-focus);
|
| 159 |
+
box-shadow: 0 0 0 3px rgba(238, 105, 131, 0.15);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.searchbar-btn {
|
| 163 |
+
display: flex;
|
| 164 |
+
align-items: center;
|
| 165 |
+
gap: 0.5rem;
|
| 166 |
+
padding: 0.875rem 1.5rem;
|
| 167 |
+
background: var(--accent-gradient);
|
| 168 |
+
color: white;
|
| 169 |
+
font-weight: 600;
|
| 170 |
+
font-size: 0.9rem;
|
| 171 |
+
border-radius: var(--radius-md);
|
| 172 |
+
transition: all var(--transition-normal);
|
| 173 |
+
white-space: nowrap;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
.searchbar-btn:hover:not(:disabled) {
|
| 177 |
+
transform: translateY(-1px);
|
| 178 |
+
box-shadow: var(--accent-glow);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.searchbar-btn:disabled {
|
| 182 |
+
opacity: 0.5;
|
| 183 |
+
cursor: not-allowed;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.searchbar-spinner {
|
| 187 |
+
width: 18px;
|
| 188 |
+
height: 18px;
|
| 189 |
+
border: 2.5px solid rgba(255, 255, 255, 0.3);
|
| 190 |
+
border-top-color: white;
|
| 191 |
+
border-radius: 50%;
|
| 192 |
+
animation: spin 0.6s linear infinite;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.searchbar-suggestions {
|
| 196 |
+
position: absolute;
|
| 197 |
+
top: calc(100% + 8px);
|
| 198 |
+
left: 0;
|
| 199 |
+
right: 0;
|
| 200 |
+
background: #850E35;
|
| 201 |
+
/* Opaque background to prevent overlap bleed-through */
|
| 202 |
+
border: 1px solid var(--border-subtle);
|
| 203 |
+
border-radius: var(--radius-md);
|
| 204 |
+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
| 205 |
+
list-style: none;
|
| 206 |
+
z-index: 9999;
|
| 207 |
+
overflow: hidden;
|
| 208 |
+
animation: fadeInUp 150ms ease;
|
| 209 |
+
backdrop-filter: blur(10px);
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
.searchbar-suggestion {
|
| 213 |
+
display: flex;
|
| 214 |
+
align-items: center;
|
| 215 |
+
gap: 0.5rem;
|
| 216 |
+
padding: 0.6rem 0.75rem;
|
| 217 |
+
cursor: pointer;
|
| 218 |
+
transition: background var(--transition-fast);
|
| 219 |
+
font-size: 0.85rem;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.searchbar-suggestion:hover,
|
| 223 |
+
.searchbar-suggestion.active {
|
| 224 |
+
background: rgba(238, 105, 131, 0.1);
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.suggestion-type-icon {
|
| 228 |
+
font-size: 0.8rem;
|
| 229 |
+
flex-shrink: 0;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.suggestion-text {
|
| 233 |
+
flex: 1;
|
| 234 |
+
color: var(--text-primary);
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
.suggestion-cat {
|
| 238 |
+
font-size: 0.65rem;
|
| 239 |
+
font-weight: 600;
|
| 240 |
+
text-transform: uppercase;
|
| 241 |
+
letter-spacing: 0.05em;
|
| 242 |
+
padding: 0.1rem 0.35rem;
|
| 243 |
+
border-radius: 3px;
|
| 244 |
+
background: rgba(238, 105, 131, 0.12);
|
| 245 |
+
color: var(--accent-secondary);
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
.searchbar-suggestion.is-history {
|
| 249 |
+
color: var(--text-secondary);
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
.searchbar-suggestion.is-history svg {
|
| 253 |
+
color: var(--text-muted);
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
.suggestion-tag {
|
| 258 |
+
font-size: 0.6rem;
|
| 259 |
+
font-weight: 500;
|
| 260 |
+
text-transform: uppercase;
|
| 261 |
+
letter-spacing: 0.05em;
|
| 262 |
+
padding: 0.1rem 0.3rem;
|
| 263 |
+
border-radius: 3px;
|
| 264 |
+
background: rgba(255, 255, 255, 0.05);
|
| 265 |
+
color: var(--text-muted);
|
| 266 |
+
margin-left: 0.5rem;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
@media (max-width: 600px) {
|
| 270 |
+
.searchbar {
|
| 271 |
+
flex-direction: column;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
.searchbar-btn {
|
| 275 |
+
justify-content: center;
|
| 276 |
+
}
|
| 277 |
+
}
|
client/src/components/SearchBar.jsx
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useRef, useMemo } from 'react';
|
| 2 |
+
import useSearchHistory from '../hooks/useSearchHistory';
|
| 3 |
+
import './SearchBar.css';
|
| 4 |
+
|
| 5 |
+
export default function SearchBar({
|
| 6 |
+
onSearch,
|
| 7 |
+
loading,
|
| 8 |
+
userLocation,
|
| 9 |
+
locationLoading,
|
| 10 |
+
locationError,
|
| 11 |
+
requestLocation,
|
| 12 |
+
clearLocation
|
| 13 |
+
}) {
|
| 14 |
+
const [query, setQuery] = useState('');
|
| 15 |
+
const [showSuggestions, setShowSuggestions] = useState(false);
|
| 16 |
+
const [activeIdx, setActiveIdx] = useState(-1);
|
| 17 |
+
const { history } = useSearchHistory();
|
| 18 |
+
const debounceRef = useRef(null);
|
| 19 |
+
const wrapperRef = useRef(null);
|
| 20 |
+
|
| 21 |
+
// Filter local history based on query
|
| 22 |
+
const historySuggestions = useMemo(() => {
|
| 23 |
+
if (!query.trim()) {
|
| 24 |
+
return history.slice(0, 5).map(h => ({ ...h, type: 'history', text: h.query }));
|
| 25 |
+
}
|
| 26 |
+
const lowerQuery = query.toLowerCase();
|
| 27 |
+
return history
|
| 28 |
+
.filter(h => h.query.toLowerCase().includes(lowerQuery))
|
| 29 |
+
.slice(0, 5)
|
| 30 |
+
.map(h => ({ ...h, type: 'history', text: h.query }));
|
| 31 |
+
}, [history, query]);
|
| 32 |
+
|
| 33 |
+
// Only show private history suggestions
|
| 34 |
+
const allSuggestions = useMemo(() => {
|
| 35 |
+
return historySuggestions;
|
| 36 |
+
}, [historySuggestions]);
|
| 37 |
+
|
| 38 |
+
useEffect(() => {
|
| 39 |
+
function handleClickOutside(e) {
|
| 40 |
+
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
|
| 41 |
+
setShowSuggestions(false);
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
document.addEventListener('mousedown', handleClickOutside);
|
| 45 |
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
| 46 |
+
}, []);
|
| 47 |
+
|
| 48 |
+
function handleInputChange(value) {
|
| 49 |
+
setQuery(value);
|
| 50 |
+
setActiveIdx(-1);
|
| 51 |
+
|
| 52 |
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
| 53 |
+
|
| 54 |
+
setShowSuggestions(true);
|
| 55 |
+
|
| 56 |
+
// We stop fetching global suggestions to respect "no global searches"
|
| 57 |
+
// and only rely on the filtered local historySuggestions.
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
function handleSelect(text) {
|
| 62 |
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
| 63 |
+
setQuery(text);
|
| 64 |
+
setShowSuggestions(false);
|
| 65 |
+
onSearch(text);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
function handleSubmit(e) {
|
| 70 |
+
e.preventDefault();
|
| 71 |
+
if (query.trim().length < 3) return;
|
| 72 |
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
| 73 |
+
setShowSuggestions(false);
|
| 74 |
+
onSearch(query.trim());
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function handleKeyDown(e) {
|
| 78 |
+
if (!showSuggestions || allSuggestions.length === 0) return;
|
| 79 |
+
|
| 80 |
+
if (e.key === 'ArrowDown') {
|
| 81 |
+
e.preventDefault();
|
| 82 |
+
setActiveIdx(prev => (prev < allSuggestions.length - 1 ? prev + 1 : 0));
|
| 83 |
+
} else if (e.key === 'ArrowUp') {
|
| 84 |
+
e.preventDefault();
|
| 85 |
+
setActiveIdx(prev => (prev > 0 ? prev - 1 : allSuggestions.length - 1));
|
| 86 |
+
} else if (e.key === 'Enter' && activeIdx >= 0) {
|
| 87 |
+
e.preventDefault();
|
| 88 |
+
handleSelect(allSuggestions[activeIdx].text);
|
| 89 |
+
} else if (e.key === 'Escape') {
|
| 90 |
+
setShowSuggestions(false);
|
| 91 |
+
} else if (e.key === 'Tab') {
|
| 92 |
+
setShowSuggestions(false);
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
return (
|
| 97 |
+
<div className="searchbar-container" ref={wrapperRef}>
|
| 98 |
+
<form className="searchbar" onSubmit={handleSubmit}>
|
| 99 |
+
<div className={`searchbar-input-wrap ${userLocation ? 'has-location' : ''}`}>
|
| 100 |
+
<button
|
| 101 |
+
type="button"
|
| 102 |
+
className={`location-btn ${userLocation ? 'active' : ''} ${locationError ? 'error' : ''}`}
|
| 103 |
+
onClick={userLocation ? clearLocation : requestLocation}
|
| 104 |
+
disabled={locationLoading || loading}
|
| 105 |
+
title={userLocation ? "Clear Location" : locationError ? locationError : "Use My Location"}
|
| 106 |
+
>
|
| 107 |
+
{locationLoading ? (
|
| 108 |
+
<span className="location-spinner"></span>
|
| 109 |
+
) : (
|
| 110 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 111 |
+
<polygon points="3 11 22 2 13 21 11 13 3 11" />
|
| 112 |
+
</svg>
|
| 113 |
+
)}
|
| 114 |
+
</button>
|
| 115 |
+
<input
|
| 116 |
+
id="search-input"
|
| 117 |
+
type="text"
|
| 118 |
+
className="searchbar-input"
|
| 119 |
+
placeholder='Search anything — add your city or use your location'
|
| 120 |
+
value={query}
|
| 121 |
+
onChange={e => handleInputChange(e.target.value)}
|
| 122 |
+
onFocus={() => { setShowSuggestions(true); }}
|
| 123 |
+
onKeyDown={handleKeyDown}
|
| 124 |
+
disabled={loading}
|
| 125 |
+
autoComplete="off"
|
| 126 |
+
/>
|
| 127 |
+
|
| 128 |
+
{showSuggestions && allSuggestions.length > 0 && !loading && (
|
| 129 |
+
<ul className="searchbar-suggestions" role="listbox">
|
| 130 |
+
{allSuggestions.map((s, i) => (
|
| 131 |
+
<li
|
| 132 |
+
key={`${s.type}-${i}`}
|
| 133 |
+
className={`searchbar-suggestion ${i === activeIdx ? 'active' : ''} ${s.type === 'history' ? 'is-history' : ''}`}
|
| 134 |
+
onClick={() => handleSelect(s.text)}
|
| 135 |
+
role="option"
|
| 136 |
+
aria-selected={i === activeIdx}
|
| 137 |
+
>
|
| 138 |
+
<span className="suggestion-type-icon">
|
| 139 |
+
{s.type === 'history' ? (
|
| 140 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 141 |
+
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
| 142 |
+
</svg>
|
| 143 |
+
) : s.type === 'place' ? '📍' : s.type === 'category' ? '📂' : '💡'}
|
| 144 |
+
</span>
|
| 145 |
+
<span className="suggestion-text">{s.text}</span>
|
| 146 |
+
{s.type === 'history' && (
|
| 147 |
+
<span className="suggestion-tag">Recent</span>
|
| 148 |
+
)}
|
| 149 |
+
{s.category && (
|
| 150 |
+
<span className="suggestion-cat">{s.category}</span>
|
| 151 |
+
)}
|
| 152 |
+
</li>
|
| 153 |
+
))}
|
| 154 |
+
</ul>
|
| 155 |
+
)}
|
| 156 |
+
</div>
|
| 157 |
+
<button
|
| 158 |
+
id="search-button"
|
| 159 |
+
type="submit"
|
| 160 |
+
className="searchbar-btn"
|
| 161 |
+
disabled={loading || query.trim().length < 3}
|
| 162 |
+
>
|
| 163 |
+
{loading ? (
|
| 164 |
+
<span className="searchbar-spinner"></span>
|
| 165 |
+
) : (
|
| 166 |
+
<>
|
| 167 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
| 168 |
+
<path d="M22 2L11 13" /><path d="M22 2L15 22L11 13L2 9L22 2Z" />
|
| 169 |
+
</svg>
|
| 170 |
+
Search
|
| 171 |
+
</>
|
| 172 |
+
)}
|
| 173 |
+
</button>
|
| 174 |
+
</form>
|
| 175 |
+
|
| 176 |
+
{userLocation ? (
|
| 177 |
+
<div className="location-active-plate">
|
| 178 |
+
<span className="active-dot"></span>
|
| 179 |
+
Using precise GPS location
|
| 180 |
+
<button type="button" className="clear-location-text" onClick={clearLocation}>
|
| 181 |
+
Clear
|
| 182 |
+
</button>
|
| 183 |
+
</div>
|
| 184 |
+
) : (
|
| 185 |
+
<div className="location-hint">
|
| 186 |
+
Tip: include your city in the search or use your location
|
| 187 |
+
</div>
|
| 188 |
+
)}
|
| 189 |
+
</div>
|
| 190 |
+
);
|
| 191 |
+
}
|
| 192 |
+
|
client/src/components/SkeletonCard.css
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.skeleton-card {
|
| 2 |
+
background: var(--bg-card);
|
| 3 |
+
border: 1px solid var(--border-subtle);
|
| 4 |
+
border-radius: var(--radius-lg);
|
| 5 |
+
padding: 1.25rem 1.5rem;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
.skeleton-line {
|
| 9 |
+
border-radius: 4px;
|
| 10 |
+
background: linear-gradient(90deg, rgba(255, 255, 255, 0.04) 25%, rgba(255, 255, 255, 0.08) 50%, rgba(255, 255, 255, 0.04) 75%);
|
| 11 |
+
background-size: 400% 100%;
|
| 12 |
+
animation: shimmer 1.5s infinite;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
.skeleton-header {
|
| 16 |
+
display: flex;
|
| 17 |
+
justify-content: space-between;
|
| 18 |
+
margin-bottom: 0.6rem;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
.skeleton-title {
|
| 22 |
+
width: 55%;
|
| 23 |
+
height: 18px;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.skeleton-badge {
|
| 27 |
+
width: 40px;
|
| 28 |
+
height: 18px;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.skeleton-address {
|
| 32 |
+
width: 70%;
|
| 33 |
+
height: 12px;
|
| 34 |
+
margin-bottom: 0.35rem;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
.skeleton-price {
|
| 38 |
+
width: 30%;
|
| 39 |
+
height: 14px;
|
| 40 |
+
margin-bottom: 0.75rem;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.skeleton-tags {
|
| 44 |
+
display: flex;
|
| 45 |
+
gap: 0.375rem;
|
| 46 |
+
margin-bottom: 0.75rem;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.skeleton-tag {
|
| 50 |
+
width: 60px;
|
| 51 |
+
height: 22px;
|
| 52 |
+
border-radius: 20px;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.skeleton-review {
|
| 56 |
+
width: 100%;
|
| 57 |
+
height: 12px;
|
| 58 |
+
margin-bottom: 0.35rem;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.skeleton-review-short {
|
| 62 |
+
width: 65%;
|
| 63 |
+
height: 12px;
|
| 64 |
+
}
|
client/src/components/SkeletonCard.jsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import './SkeletonCard.css';
|
| 2 |
+
|
| 3 |
+
export default function SkeletonCard() {
|
| 4 |
+
return (
|
| 5 |
+
<div className="skeleton-card">
|
| 6 |
+
<div className="skeleton-header">
|
| 7 |
+
<div className="skeleton-line skeleton-title"></div>
|
| 8 |
+
<div className="skeleton-line skeleton-badge"></div>
|
| 9 |
+
</div>
|
| 10 |
+
<div className="skeleton-line skeleton-address"></div>
|
| 11 |
+
<div className="skeleton-line skeleton-price"></div>
|
| 12 |
+
<div className="skeleton-tags">
|
| 13 |
+
<div className="skeleton-line skeleton-tag"></div>
|
| 14 |
+
<div className="skeleton-line skeleton-tag"></div>
|
| 15 |
+
<div className="skeleton-line skeleton-tag"></div>
|
| 16 |
+
</div>
|
| 17 |
+
<div className="skeleton-line skeleton-review"></div>
|
| 18 |
+
<div className="skeleton-line skeleton-review-short"></div>
|
| 19 |
+
</div>
|
| 20 |
+
);
|
| 21 |
+
}
|
client/src/contexts/ToastContext.css
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.toast-container {
|
| 2 |
+
position: fixed;
|
| 3 |
+
bottom: 2rem;
|
| 4 |
+
right: 2rem;
|
| 5 |
+
display: flex;
|
| 6 |
+
flex-direction: column;
|
| 7 |
+
gap: 0.75rem;
|
| 8 |
+
z-index: 9999;
|
| 9 |
+
pointer-events: none;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
.toast {
|
| 13 |
+
pointer-events: auto;
|
| 14 |
+
background: var(--bg-card);
|
| 15 |
+
color: var(--text-primary);
|
| 16 |
+
padding: 1rem 1.25rem;
|
| 17 |
+
border-radius: var(--radius-md);
|
| 18 |
+
border: 1px solid var(--border-subtle);
|
| 19 |
+
box-shadow: var(--shadow-lg);
|
| 20 |
+
display: flex;
|
| 21 |
+
align-items: center;
|
| 22 |
+
gap: 0.75rem;
|
| 23 |
+
font-size: 0.9rem;
|
| 24 |
+
font-weight: 500;
|
| 25 |
+
min-width: 250px;
|
| 26 |
+
max-width: 400px;
|
| 27 |
+
animation-duration: 0.3s;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
.toast-success {
|
| 31 |
+
border-left: 4px solid var(--success);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.toast-error {
|
| 35 |
+
border-left: 4px solid #ff7675;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.toast-info {
|
| 39 |
+
border-left: 4px solid var(--accent-primary);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
.toast-close {
|
| 43 |
+
margin-left: auto;
|
| 44 |
+
background: none;
|
| 45 |
+
border: none;
|
| 46 |
+
color: var(--text-muted);
|
| 47 |
+
font-size: 1.25rem;
|
| 48 |
+
line-height: 1;
|
| 49 |
+
cursor: pointer;
|
| 50 |
+
padding: 0 0.25rem;
|
| 51 |
+
transition: color var(--transition-fast);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.toast-close:hover {
|
| 55 |
+
color: var(--text-primary);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
@media (max-width: 600px) {
|
| 59 |
+
.toast-container {
|
| 60 |
+
bottom: 1rem;
|
| 61 |
+
left: 1rem;
|
| 62 |
+
right: 1rem;
|
| 63 |
+
align-items: center;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
.toast {
|
| 67 |
+
width: 100%;
|
| 68 |
+
max-width: 100%;
|
| 69 |
+
}
|
| 70 |
+
}
|
client/src/contexts/ToastContext.jsx
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { createContext, useContext, useState, useCallback } from 'react';
|
| 4 |
+
import './ToastContext.css';
|
| 5 |
+
|
| 6 |
+
const ToastContext = createContext(null);
|
| 7 |
+
|
| 8 |
+
export function ToastProvider({ children }) {
|
| 9 |
+
const [toasts, setToasts] = useState([]);
|
| 10 |
+
|
| 11 |
+
const addToast = useCallback((message, type = 'info', duration = 3000) => {
|
| 12 |
+
const id = Date.now();
|
| 13 |
+
setToasts(prev => [...prev, { id, message, type }]);
|
| 14 |
+
|
| 15 |
+
setTimeout(() => {
|
| 16 |
+
setToasts(prev => prev.filter(t => t.id !== id));
|
| 17 |
+
}, duration);
|
| 18 |
+
}, []);
|
| 19 |
+
|
| 20 |
+
const removeToast = useCallback(id => {
|
| 21 |
+
setToasts(prev => prev.filter(t => t.id !== id));
|
| 22 |
+
}, []);
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<ToastContext.Provider value={{ addToast }}>
|
| 26 |
+
{children}
|
| 27 |
+
<div className="toast-container" aria-live="polite">
|
| 28 |
+
{toasts.map(toast => (
|
| 29 |
+
<div key={toast.id} className={`toast toast-${toast.type} fade-in-up`}>
|
| 30 |
+
{toast.type === 'success' && '✅ '}
|
| 31 |
+
{toast.type === 'error' && '❌ '}
|
| 32 |
+
{toast.type === 'info' && '💡 '}
|
| 33 |
+
{toast.message}
|
| 34 |
+
<button
|
| 35 |
+
className="toast-close"
|
| 36 |
+
onClick={() => removeToast(toast.id)}
|
| 37 |
+
aria-label="Close"
|
| 38 |
+
>
|
| 39 |
+
×
|
| 40 |
+
</button>
|
| 41 |
+
</div>
|
| 42 |
+
))}
|
| 43 |
+
</div>
|
| 44 |
+
</ToastContext.Provider>
|
| 45 |
+
);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export function useToast() {
|
| 49 |
+
const context = useContext(ToastContext);
|
| 50 |
+
if (!context) {
|
| 51 |
+
throw new Error('useToast must be used within a ToastProvider');
|
| 52 |
+
}
|
| 53 |
+
return context;
|
| 54 |
+
}
|
client/src/hooks/useGeolocation.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
|
| 3 |
+
export function useGeolocation() {
|
| 4 |
+
const [location, setLocation] = useState(null);
|
| 5 |
+
const [error, setError] = useState(null);
|
| 6 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 7 |
+
|
| 8 |
+
const requestLocation = () => {
|
| 9 |
+
setIsLoading(true);
|
| 10 |
+
setError(null);
|
| 11 |
+
|
| 12 |
+
if (!navigator.geolocation) {
|
| 13 |
+
setError('Geolocation is not supported by your browser');
|
| 14 |
+
setIsLoading(false);
|
| 15 |
+
return;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
navigator.geolocation.getCurrentPosition(
|
| 19 |
+
(position) => {
|
| 20 |
+
setLocation({
|
| 21 |
+
lat: position.coords.latitude,
|
| 22 |
+
lng: position.coords.longitude
|
| 23 |
+
});
|
| 24 |
+
setIsLoading(false);
|
| 25 |
+
},
|
| 26 |
+
(err) => {
|
| 27 |
+
setError(err.message || 'Failed to retrieve location');
|
| 28 |
+
setIsLoading(false);
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
enableHighAccuracy: true,
|
| 32 |
+
timeout: 10000,
|
| 33 |
+
maximumAge: 0
|
| 34 |
+
}
|
| 35 |
+
);
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
const clearLocation = () => {
|
| 39 |
+
setLocation(null);
|
| 40 |
+
setError(null);
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
return { location, error, isLoading, requestLocation, clearLocation };
|
| 44 |
+
}
|