initial commit
This commit is contained in:
117
.cursor/rules/color-theme.mdc
Normal file
117
.cursor/rules/color-theme.mdc
Normal file
@@ -0,0 +1,117 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
# Frontend Color Theme Guidelines
|
||||
|
||||
## Color Palette
|
||||
|
||||
### Primary Colors
|
||||
- **Primary Peach**: `#FFCDB2` - Main brand color, used for primary buttons, links, and accents
|
||||
- **Secondary Coral**: `#FFB4A2` - Used for gradients and secondary elements
|
||||
- **Light Pink**: `#E5989B` - Hover states and secondary buttons
|
||||
- **Medium Mauve**: `#B5838D` - Alternative secondary color
|
||||
- **Dark Purple**: `#6D6875` - Text and dark accents
|
||||
|
||||
### Background Colors
|
||||
- **Dark Mode Background**: `#6D6875` - Main dark theme background
|
||||
- **Light Mode Background**: `#FFCDB2` - Main light theme background (very light)
|
||||
- **Card Background**: `white` - Card and component backgrounds
|
||||
|
||||
### Text Colors
|
||||
- **Primary Text (Dark)**: `#6D6875` - Main text color in light mode
|
||||
- **Primary Text (Light)**: `rgba(255, 255, 255, 0.87)` - Main text color in dark mode
|
||||
- **Secondary Text**: `#B5838D` - Subtitle and body text
|
||||
- **Accent Text**: `#E5989B` - Card subtitles and accent text
|
||||
|
||||
### Interactive States
|
||||
- **Hover Effects**: Use `transform: translateY(-2px)` for subtle lift animations
|
||||
- **Focus Rings**: `rgba(255, 205, 178, 0.3)` - Peach focus outline
|
||||
- **Shadows**: `rgba(255, 205, 178, 0.3)` for primary elements, `rgba(229, 152, 155, 0.3)` for secondary
|
||||
|
||||
## CSS Custom Properties
|
||||
|
||||
Always define colors using CSS custom properties in [src/index.css](mdc:src/index.css):
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Primary Colors */
|
||||
--color-primary: #FFCDB2;
|
||||
--color-primary-dark: #FFB4A2;
|
||||
--color-secondary: #E5989B;
|
||||
--color-secondary-dark: #B5838D;
|
||||
--color-dark: #6D6875;
|
||||
|
||||
/* Background Colors */
|
||||
--color-bg-dark: #6D6875;
|
||||
--color-bg-light: #FFCDB2;
|
||||
--color-bg-card: white;
|
||||
|
||||
/* Text Colors */
|
||||
--color-text-primary: #6D6875;
|
||||
--color-text-primary-light: rgba(255, 255, 255, 0.87);
|
||||
--color-text-secondary: #B5838D;
|
||||
--color-text-accent: #E5989B;
|
||||
|
||||
/* Interactive Colors */
|
||||
--color-focus: rgba(255, 205, 178, 0.3);
|
||||
--color-shadow-primary: rgba(255, 205, 178, 0.3);
|
||||
--color-shadow-secondary: rgba(229, 152, 155, 0.3);
|
||||
}
|
||||
```
|
||||
|
||||
## Component Styling Guidelines
|
||||
|
||||
### Buttons
|
||||
Follow the patterns in [src/components/Button.css](mdc:src/components/Button.css):
|
||||
- Use gradient backgrounds for primary/secondary buttons
|
||||
- Implement hover animations with `translateY(-2px)`
|
||||
- Apply consistent focus states with green outline
|
||||
- Use semantic class names: `.btn-primary`, `.btn-secondary`, `.btn-outline`, `.btn-ghost`
|
||||
|
||||
### Cards
|
||||
Follow the patterns in [src/components/Card.css](mdc:src/components/Card.css):
|
||||
- Use white background with subtle shadows
|
||||
- Implement hover animations with `translateY(-8px)`
|
||||
- Use consistent border radius (16px)
|
||||
- Apply image hover effects with `scale(1.05)`
|
||||
|
||||
### Links
|
||||
- Primary color: `#FFCDB2`
|
||||
- Hover color: `#E5989B` (dark mode), `#B5838D` (light mode)
|
||||
- No text decoration by default
|
||||
|
||||
## Dark/Light Mode Support
|
||||
|
||||
Always include both dark and light mode variants using `@media (prefers-color-scheme: light)` in [src/index.css](mdc:src/index.css).
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Maintain minimum contrast ratios (4.5:1 for normal text, 3:1 for large text)
|
||||
- Use focus indicators with `--color-focus`
|
||||
- Ensure hover states are distinct from focus states
|
||||
- Test with color blindness simulators
|
||||
|
||||
## File Organization
|
||||
|
||||
- Global styles and CSS custom properties: [src/index.css](mdc:src/index.css)
|
||||
- Component-specific styles: Individual `.css` files in [src/components/](mdc:src/components/)
|
||||
- Utility classes: [src/App.css](mdc:src/App.css)
|
||||
- Consider creating a dedicated [src/styles/](mdc:src/styles/) directory for theme files
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```css
|
||||
/* Button with theme colors */
|
||||
.my-button {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-dark);
|
||||
box-shadow: 0 4px 12px var(--color-shadow-primary);
|
||||
}
|
||||
|
||||
/* Card with theme colors */
|
||||
.my-card {
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid rgba(109, 104, 117, 0.1);
|
||||
}
|
||||
```
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
222
frontend/README.md
Normal file
222
frontend/README.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# Vibing - React + Vite Application
|
||||
|
||||
A modern React application built with Vite, featuring beautiful components and a responsive design that adapts to any device.
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
- **Fast Development**: Built with Vite for lightning-fast development and hot module replacement
|
||||
- **Modern React**: Using React 19 with modern hooks and patterns
|
||||
- **Responsive Design**: Fully responsive design that looks great on all devices
|
||||
- **Component Library**: Reusable components with consistent styling
|
||||
- **Custom Hooks**: Useful custom hooks for common functionality
|
||||
- **API Utilities**: Built-in API utilities with error handling
|
||||
- **Modern Styling**: Beautiful gradients and modern CSS design
|
||||
|
||||
## 📦 Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # Reusable UI components
|
||||
│ ├── Header.jsx # Navigation header
|
||||
│ ├── Footer.jsx # Site footer
|
||||
│ ├── Button.jsx # Reusable button component
|
||||
│ ├── Card.jsx # Card component for content
|
||||
│ └── index.js # Component exports
|
||||
├── hooks/ # Custom React hooks
|
||||
│ └── useLocalStorage.js
|
||||
├── pages/ # Page components
|
||||
│ ├── Home.jsx # Home page
|
||||
│ └── Home.css
|
||||
├── utils/ # Utility functions
|
||||
│ └── api.js # API utilities
|
||||
├── styles/ # Global styles (future use)
|
||||
├── assets/ # Static assets
|
||||
├── App.jsx # Main app component
|
||||
├── App.css # Global styles
|
||||
├── main.jsx # App entry point
|
||||
└── index.css # Base styles
|
||||
```
|
||||
|
||||
## 🛠️ Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (version 16 or higher)
|
||||
- npm or yarn
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd vibing
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
4. Open your browser and navigate to `http://localhost:5173`
|
||||
|
||||
## 📜 Available Scripts
|
||||
|
||||
- `npm run dev` - Start development server
|
||||
- `npm run build` - Build for production
|
||||
- `npm run preview` - Preview production build
|
||||
- `npm run lint` - Run ESLint
|
||||
|
||||
## 🎨 Components
|
||||
|
||||
### Button Component
|
||||
|
||||
```jsx
|
||||
import { Button } from './components';
|
||||
|
||||
// Different variants
|
||||
<Button variant="primary">Primary Button</Button>
|
||||
<Button variant="secondary">Secondary Button</Button>
|
||||
<Button variant="outline">Outline Button</Button>
|
||||
<Button variant="ghost">Ghost Button</Button>
|
||||
|
||||
// Different sizes
|
||||
<Button size="small">Small</Button>
|
||||
<Button size="medium">Medium</Button>
|
||||
<Button size="large">Large</Button>
|
||||
```
|
||||
|
||||
### Card Component
|
||||
|
||||
```jsx
|
||||
import { Card } from './components';
|
||||
|
||||
<Card
|
||||
title="Card Title"
|
||||
subtitle="Card subtitle"
|
||||
image="https://example.com/image.jpg"
|
||||
>
|
||||
<p>Card content goes here...</p>
|
||||
</Card>
|
||||
```
|
||||
|
||||
### Header & Footer
|
||||
|
||||
```jsx
|
||||
import { Header, Footer } from './components';
|
||||
|
||||
// Header with navigation
|
||||
<Header />
|
||||
|
||||
// Footer with links and social media
|
||||
<Footer />
|
||||
```
|
||||
|
||||
## 🔧 Custom Hooks
|
||||
|
||||
### useLocalStorage
|
||||
|
||||
```jsx
|
||||
import useLocalStorage from './hooks/useLocalStorage';
|
||||
|
||||
const [value, setValue] = useLocalStorage('key', initialValue);
|
||||
```
|
||||
|
||||
## 🌐 API Utilities
|
||||
|
||||
```jsx
|
||||
import { api } from './utils/api';
|
||||
|
||||
// GET request
|
||||
const data = await api.get('/users');
|
||||
|
||||
// POST request
|
||||
const newUser = await api.post('/users', { name: 'John', email: 'john@example.com' });
|
||||
|
||||
// PUT request
|
||||
const updatedUser = await api.put('/users/1', { name: 'Jane' });
|
||||
|
||||
// DELETE request
|
||||
await api.delete('/users/1');
|
||||
```
|
||||
|
||||
## 🎯 Environment Variables
|
||||
|
||||
Create a `.env` file in the root directory:
|
||||
|
||||
```env
|
||||
VITE_API_BASE_URL=http://localhost:3000/api
|
||||
```
|
||||
|
||||
## 📱 Responsive Design
|
||||
|
||||
The application is fully responsive and includes:
|
||||
|
||||
- Mobile-first design approach
|
||||
- Flexible grid layouts
|
||||
- Responsive typography
|
||||
- Touch-friendly interactions
|
||||
- Optimized for all screen sizes
|
||||
|
||||
## 🎨 Styling
|
||||
|
||||
The project uses:
|
||||
|
||||
- CSS modules for component-specific styles
|
||||
- CSS custom properties for theming
|
||||
- Flexbox and Grid for layouts
|
||||
- Modern CSS features like gradients and animations
|
||||
- Responsive design patterns
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
### Build for Production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
The build output will be in the `dist` directory, ready for deployment to any static hosting service.
|
||||
|
||||
### Deploy to Vercel
|
||||
|
||||
1. Install Vercel CLI:
|
||||
```bash
|
||||
npm i -g vercel
|
||||
```
|
||||
|
||||
2. Deploy:
|
||||
```bash
|
||||
vercel
|
||||
```
|
||||
|
||||
### Deploy to Netlify
|
||||
|
||||
1. Build the project:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. Upload the `dist` folder to Netlify
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- [Vite](https://vitejs.dev/) for the fast build tool
|
||||
- [React](https://reactjs.org/) for the UI library
|
||||
- [Unsplash](https://unsplash.com/) for the beautiful images
|
||||
10
frontend/env.example
Normal file
10
frontend/env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
# API Configuration
|
||||
VITE_API_BASE_URL=http://localhost:3000/api
|
||||
|
||||
# App Configuration
|
||||
VITE_APP_TITLE=Vibing
|
||||
VITE_APP_DESCRIPTION=A modern React application built with Vite
|
||||
|
||||
# Feature Flags
|
||||
VITE_ENABLE_ANALYTICS=false
|
||||
VITE_ENABLE_DEBUG_MODE=false
|
||||
29
frontend/eslint.config.js
Normal file
29
frontend/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2850
frontend/package-lock.json
generated
Normal file
2850
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/package.json
Normal file
28
frontend/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "vibing",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
75
frontend/src/App.css
Normal file
75
frontend/src/App.css
Normal file
@@ -0,0 +1,75 @@
|
||||
/* Global styles */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* Reset some default styles */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Responsive utilities */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
}
|
||||
19
frontend/src/App.jsx
Normal file
19
frontend/src/App.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { LanguageProvider } from './contexts/LanguageContext';
|
||||
import Home from './pages/Home';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<LanguageProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</LanguageProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
87
frontend/src/components/Button.css
Normal file
87
frontend/src/components/Button.css
Normal file
@@ -0,0 +1,87 @@
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%);
|
||||
color: var(--color-dark);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px var(--color-shadow-primary);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: linear-gradient(135deg, var(--color-secondary) 0%, var(--color-secondary-dark) 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px var(--color-shadow-secondary);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
border: 2px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-outline:hover:not(:disabled) {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-dark);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: rgba(255, 205, 178, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.btn-small {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-medium {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Focus states */
|
||||
.btn:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--color-focus);
|
||||
}
|
||||
|
||||
.btn-outline:focus {
|
||||
box-shadow: 0 0 0 3px var(--color-focus);
|
||||
}
|
||||
29
frontend/src/components/Button.jsx
Normal file
29
frontend/src/components/Button.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import './Button.css';
|
||||
|
||||
const Button = ({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
onClick,
|
||||
disabled = false,
|
||||
type = 'button',
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
const buttonClasses = `btn btn-${variant} btn-${size} ${className}`.trim();
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={buttonClasses}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
69
frontend/src/components/Card.css
Normal file
69
frontend/src/components/Card.css
Normal file
@@ -0,0 +1,69 @@
|
||||
.card {
|
||||
background: var(--color-bg-card);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid rgba(109, 104, 117, 0.1);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.card-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover .card-image img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--color-text-accent);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Card without image */
|
||||
.card:not(:has(.card-image)) .card-content {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.card-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card:not(:has(.card-image)) .card-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
31
frontend/src/components/Card.jsx
Normal file
31
frontend/src/components/Card.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import './Card.css';
|
||||
|
||||
const Card = ({
|
||||
children,
|
||||
title,
|
||||
subtitle,
|
||||
image,
|
||||
imageAlt,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div className={`card ${className}`.trim()} {...props}>
|
||||
{image && (
|
||||
<div className="card-image">
|
||||
<img src={image} alt={imageAlt || title} />
|
||||
</div>
|
||||
)}
|
||||
<div className="card-content">
|
||||
{title && <h3 className="card-title">{title}</h3>}
|
||||
{subtitle && <p className="card-subtitle">{subtitle}</p>}
|
||||
<div className="card-body">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
76
frontend/src/components/Footer.css
Normal file
76
frontend/src/components/Footer.css
Normal file
@@ -0,0 +1,76 @@
|
||||
.footer {
|
||||
background: linear-gradient(135deg, var(--color-dark) 0%, var(--color-secondary-dark) 100%);
|
||||
color: var(--color-text-primary-light);
|
||||
padding: 3rem 0 1rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.footer-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.footer-section h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.footer-section h4 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.2rem;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.footer-section p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-primary-light);
|
||||
}
|
||||
|
||||
.footer-section ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.footer-section ul li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.footer-section ul li a {
|
||||
color: var(--color-text-primary-light);
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.footer-section ul li a:hover {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
border-top: 1px solid var(--color-secondary-dark);
|
||||
padding-top: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-bottom p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.footer-content {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
40
frontend/src/components/Footer.jsx
Normal file
40
frontend/src/components/Footer.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import './Footer.css';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
const Footer = () => {
|
||||
const { t } = useLanguage();
|
||||
return (
|
||||
<footer className="footer">
|
||||
<div className="footer-container">
|
||||
<div className="footer-content">
|
||||
<div className="footer-section">
|
||||
<h3>Phun</h3>
|
||||
<p>{t('footerDescription')}</p>
|
||||
</div>
|
||||
<div className="footer-section">
|
||||
<h4>Links</h4>
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/about">About</a></li>
|
||||
<li><a href="/contact">Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="footer-section">
|
||||
<h4>Connect</h4>
|
||||
<ul>
|
||||
<li><a href="https://github.com" target="_blank" rel="noopener noreferrer">GitHub</a></li>
|
||||
<li><a href="https://twitter.com" target="_blank" rel="noopener noreferrer">Twitter</a></li>
|
||||
<li><a href="https://linkedin.com" target="_blank" rel="noopener noreferrer">LinkedIn</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer-bottom">
|
||||
<p>© 2024 Phun. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
68
frontend/src/components/Header.css
Normal file
68
frontend/src/components/Header.css
Normal file
@@ -0,0 +1,68 @@
|
||||
.header {
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%);
|
||||
color: var(--color-dark);
|
||||
padding: 1rem 0;
|
||||
box-shadow: 0 2px 10px var(--color-shadow-primary);
|
||||
}
|
||||
|
||||
.header-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(45deg, var(--color-dark), var(--color-secondary-dark));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--color-dark);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-container {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
27
frontend/src/components/Header.jsx
Normal file
27
frontend/src/components/Header.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import LanguageSwitcher from './LanguageSwitcher';
|
||||
import './Header.css';
|
||||
|
||||
const Header = () => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="header-container">
|
||||
<h1 className="header-title">Phun</h1>
|
||||
<nav className="header-nav">
|
||||
<ul className="nav-list">
|
||||
<li><Link to="/" className="nav-link">{t('home')}</Link></li>
|
||||
<li><Link to="/about" className="nav-link">{t('about')}</Link></li>
|
||||
<li><Link to="/contact" className="nav-link">{t('contact')}</Link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
89
frontend/src/components/LanguageSwitcher.css
Normal file
89
frontend/src/components/LanguageSwitcher.css
Normal file
@@ -0,0 +1,89 @@
|
||||
.language-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.language-label {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.language-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: rgba(109, 104, 117, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.language-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.language-btn:hover {
|
||||
background: rgba(255, 205, 178, 0.2);
|
||||
color: var(--color-text-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.language-btn.active {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-dark);
|
||||
box-shadow: 0 2px 8px var(--color-shadow-primary);
|
||||
}
|
||||
|
||||
.language-btn:focus {
|
||||
outline: 2px solid var(--color-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.language-buttons {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.language-btn {
|
||||
color: var(--color-text-primary-light);
|
||||
}
|
||||
|
||||
.language-btn:hover {
|
||||
background: rgba(255, 205, 178, 0.15);
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.language-btn.active {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-dark);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.language-switcher {
|
||||
margin-left: 0;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.language-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.language-btn {
|
||||
padding: 5px 10px;
|
||||
font-size: 13px;
|
||||
min-width: 50px;
|
||||
}
|
||||
}
|
||||
35
frontend/src/components/LanguageSwitcher.jsx
Normal file
35
frontend/src/components/LanguageSwitcher.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import './LanguageSwitcher.css';
|
||||
|
||||
const LanguageSwitcher = () => {
|
||||
const { language, setLanguage, t } = useLanguage();
|
||||
|
||||
const handleLanguageChange = (newLanguage) => {
|
||||
setLanguage(newLanguage);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="language-switcher">
|
||||
<span className="language-label">{t('language')}:</span>
|
||||
<div className="language-buttons">
|
||||
<button
|
||||
className={`language-btn ${language === 'en' ? 'active' : ''}`}
|
||||
onClick={() => handleLanguageChange('en')}
|
||||
aria-label="Switch to English"
|
||||
>
|
||||
{t('english')}
|
||||
</button>
|
||||
<button
|
||||
className={`language-btn ${language === 'fi' ? 'active' : ''}`}
|
||||
onClick={() => handleLanguageChange('fi')}
|
||||
aria-label="Vaihda suomeksi"
|
||||
>
|
||||
{t('finnish')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSwitcher;
|
||||
5
frontend/src/components/index.js
Normal file
5
frontend/src/components/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as Header } from './Header';
|
||||
export { default as Footer } from './Footer';
|
||||
export { default as Button } from './Button';
|
||||
export { default as Card } from './Card';
|
||||
export { default as LanguageSwitcher } from './LanguageSwitcher';
|
||||
103
frontend/src/contexts/LanguageContext.jsx
Normal file
103
frontend/src/contexts/LanguageContext.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
|
||||
const LanguageContext = createContext();
|
||||
|
||||
export const useLanguage = () => {
|
||||
const context = useContext(LanguageContext);
|
||||
if (!context) {
|
||||
throw new Error('useLanguage must be used within a LanguageProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
// Header
|
||||
home: 'Home',
|
||||
about: 'About',
|
||||
contact: 'Contact',
|
||||
|
||||
// CTA Section
|
||||
readyToStart: 'Ready to get started?',
|
||||
ctaSubtitle: 'Join thousands of developers building amazing applications with our modern stack.',
|
||||
startBuilding: 'Start Building',
|
||||
|
||||
// Language Switcher
|
||||
language: 'Language',
|
||||
english: 'English',
|
||||
finnish: 'Finnish',
|
||||
|
||||
// Search Page
|
||||
searchPlaceholder: 'Enter your search query...',
|
||||
searchButton: 'Search',
|
||||
searchResults: 'Search Results',
|
||||
searchResultsSubtitle: 'Here are the results for your search query.',
|
||||
searchResultsPlaceholder: 'Search results will appear here. Try entering a search term above to get started.',
|
||||
|
||||
// Search Filters
|
||||
dogFriendly: 'Dog Friendly',
|
||||
accessible: 'Accessible',
|
||||
familyFriendly: 'Family Friendly',
|
||||
peopleCount: 'People',
|
||||
priceRange: 'Price',
|
||||
any: 'Any',
|
||||
|
||||
// Footer
|
||||
footerDescription: 'Find fun things to do!',
|
||||
},
|
||||
fi: {
|
||||
// Header
|
||||
home: 'Koti',
|
||||
about: 'Tietoja',
|
||||
contact: 'Yhteystiedot',
|
||||
|
||||
|
||||
// CTA Section
|
||||
readyToStart: 'Valmiina aloittamaan?',
|
||||
ctaSubtitle: 'Liity tuhansien kehittäjien joukkoon, jotka rakentavat upeita sovelluksia modernilla teknologiapinoamme.',
|
||||
startBuilding: 'Aloita rakentaminen',
|
||||
|
||||
// Language Switcher
|
||||
language: 'Kieli',
|
||||
english: 'Englanti',
|
||||
finnish: 'Suomi',
|
||||
|
||||
// Search Page
|
||||
searchPlaceholder: 'Syötä hakukyselysi...',
|
||||
searchButton: 'Hae',
|
||||
searchResults: 'Hakutulokset',
|
||||
searchResultsSubtitle: 'Tässä ovat hakutulokset kyselysi perusteella.',
|
||||
searchResultsPlaceholder: 'Hakutulokset näkyvät täällä. Kokeile syöttää hakusana yllä aloittaaksesi.',
|
||||
|
||||
// Search Filters
|
||||
dogFriendly: 'Koiraystävällinen',
|
||||
accessible: 'Esteetön',
|
||||
familyFriendly: 'Lapsiystävällinen',
|
||||
peopleCount: 'Henkilöt',
|
||||
priceRange: 'Hinta',
|
||||
any: 'Mikä tahansa',
|
||||
|
||||
// Footer
|
||||
footerDescription: 'Löydä hauskaa tekemistä!',
|
||||
}
|
||||
};
|
||||
|
||||
export const LanguageProvider = ({ children }) => {
|
||||
const [language, setLanguage] = useState('en');
|
||||
|
||||
const t = (key) => {
|
||||
return translations[language][key] || key;
|
||||
};
|
||||
|
||||
const value = {
|
||||
language,
|
||||
setLanguage,
|
||||
t
|
||||
};
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={value}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
};
|
||||
30
frontend/src/hooks/useLocalStorage.js
Normal file
30
frontend/src/hooks/useLocalStorage.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const useLocalStorage = (key, initialValue) => {
|
||||
// Get from local storage then parse stored json or return initialValue
|
||||
const [storedValue, setStoredValue] = useState(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
console.error(`Error reading localStorage key "${key}":`, error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
// Return a wrapped version of useState's setter function that persists the new value to localStorage
|
||||
const setValue = (value) => {
|
||||
try {
|
||||
// Allow value to be a function so we have the same API as useState
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||
setStoredValue(valueToStore);
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
} catch (error) {
|
||||
console.error(`Error setting localStorage key "${key}":`, error);
|
||||
}
|
||||
};
|
||||
|
||||
return [storedValue, setValue];
|
||||
};
|
||||
|
||||
export default useLocalStorage;
|
||||
89
frontend/src/index.css
Normal file
89
frontend/src/index.css
Normal file
@@ -0,0 +1,89 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #6D6875;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
/* Primary Colors */
|
||||
--color-primary: #FFCDB2;
|
||||
--color-primary-dark: #FFB4A2;
|
||||
--color-secondary: #E5989B;
|
||||
--color-secondary-dark: #B5838D;
|
||||
--color-dark: #6D6875;
|
||||
|
||||
/* Background Colors */
|
||||
--color-bg-dark: #6D6875;
|
||||
--color-bg-light: #FFCDB2;
|
||||
--color-bg-card: white;
|
||||
|
||||
/* Text Colors */
|
||||
--color-text-primary: #6D6875;
|
||||
--color-text-primary-light: rgba(255, 255, 255, 0.87);
|
||||
--color-text-secondary: #B5838D;
|
||||
--color-text-accent: #E5989B;
|
||||
|
||||
/* Interactive Colors */
|
||||
--color-focus: rgba(255, 205, 178, 0.3);
|
||||
--color-shadow-primary: rgba(255, 205, 178, 0.3);
|
||||
--color-shadow-secondary: rgba(229, 152, 155, 0.3);
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: var(--color-primary);
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: var(--color-bg-dark);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto var(--color-focus);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg-light);
|
||||
}
|
||||
a:hover {
|
||||
color: var(--color-secondary-dark);
|
||||
}
|
||||
button {
|
||||
background-color: var(--color-bg-light);
|
||||
}
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
337
frontend/src/pages/Home.css
Normal file
337
frontend/src/pages/Home.css
Normal file
@@ -0,0 +1,337 @@
|
||||
.home {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero {
|
||||
background: linear-gradient(rgba(109, 104, 117, 0.4), rgba(109, 104, 117, 0.4)), url('https://images.unsplash.com/photo-1429305336325-b84ace7eba3b?q=80&w=1770&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-attachment: fixed;
|
||||
color: white;
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 800;
|
||||
margin: 0 0 3rem 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: linear-gradient(45deg, var(--color-primary), var(--color-secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Search Toolbar in Hero */
|
||||
.search-toolbar {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 1.5rem;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.toolbar-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Search Input Container */
|
||||
.search-input-container {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 50px;
|
||||
padding: 0.5rem;
|
||||
box-shadow: 0 8px 32px var(--color-shadow-primary);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input-container:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 40px var(--color-shadow-primary);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
border-radius: 25px;
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--color-text-secondary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
background: linear-gradient(135deg, var(--color-secondary) 0%, var(--color-secondary-dark) 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 25px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 12px var(--color-shadow-secondary);
|
||||
}
|
||||
|
||||
.search-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px var(--color-shadow-secondary);
|
||||
}
|
||||
|
||||
.search-button:focus {
|
||||
outline: 2px solid var(--color-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Filters Container */
|
||||
.filters-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 25px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 12px rgba(109, 104, 117, 0.1);
|
||||
}
|
||||
|
||||
.filter-label:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(109, 104, 117, 0.15);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.filter-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--color-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-text {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.filter-select:hover {
|
||||
background: rgba(229, 152, 155, 0.1);
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
background: rgba(229, 152, 155, 0.2);
|
||||
}
|
||||
|
||||
/* Features Section */
|
||||
.features {
|
||||
padding: 5rem 0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 3rem 0;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.hero {
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar-content {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.search-input-container {
|
||||
flex-direction: column;
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.search-input,
|
||||
.search-button {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.filters-container {
|
||||
gap: 1rem;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
min-width: 200px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.features {
|
||||
padding: 3rem 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.hero-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.search-toolbar {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
min-width: 180px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Mode Support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.search-toolbar {
|
||||
background: rgba(109, 104, 117, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.search-input-container {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
color: var(--color-text-primary-light);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.filter-label:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.filter-text {
|
||||
color: var(--color-text-primary-light);
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
color: var(--color-text-primary-light);
|
||||
}
|
||||
|
||||
.filter-select:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.features {
|
||||
background: var(--color-bg-dark);
|
||||
}
|
||||
}
|
||||
150
frontend/src/pages/Home.jsx
Normal file
150
frontend/src/pages/Home.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Header, Footer, Button, Card } from '../components';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import './Home.css';
|
||||
|
||||
const Home = () => {
|
||||
const { t } = useLanguage();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filters, setFilters] = useState({
|
||||
dogFriendly: false,
|
||||
accessible: false,
|
||||
familyFriendly: false,
|
||||
peopleCount: '',
|
||||
priceRange: ''
|
||||
});
|
||||
|
||||
const handleGetStarted = () => {
|
||||
alert('Get Started button clicked!');
|
||||
};
|
||||
|
||||
const handleLearnMore = () => {
|
||||
alert('Learn More button clicked!');
|
||||
};
|
||||
|
||||
const handleFilterChange = (filterName, value) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[filterName]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
// Handle search logic here
|
||||
console.log('Search query:', searchQuery);
|
||||
console.log('Filters:', filters);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<Header />
|
||||
|
||||
<main className="main-content">
|
||||
{/* Hero Section */}
|
||||
<section className="hero">
|
||||
<div className="hero-content">
|
||||
|
||||
{/* Search Toolbar */}
|
||||
<div className="search-toolbar">
|
||||
<div className="toolbar-content">
|
||||
{/* Search Input */}
|
||||
<div className="search-input-container">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder={t('searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
<button className="search-button" onClick={handleSearch}>
|
||||
{t('searchButton')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="filters-container">
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="filter-checkbox"
|
||||
checked={filters.dogFriendly}
|
||||
onChange={(e) => handleFilterChange('dogFriendly', e.target.checked)}
|
||||
/>
|
||||
<span className="filter-text">{t('dogFriendly')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="filter-checkbox"
|
||||
checked={filters.accessible}
|
||||
onChange={(e) => handleFilterChange('accessible', e.target.checked)}
|
||||
/>
|
||||
<span className="filter-text">{t('accessible')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="filter-checkbox"
|
||||
checked={filters.familyFriendly}
|
||||
onChange={(e) => handleFilterChange('familyFriendly', e.target.checked)}
|
||||
/>
|
||||
<span className="filter-text">{t('familyFriendly')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">
|
||||
<span className="filter-text">{t('peopleCount')}:</span>
|
||||
<select
|
||||
className="filter-select"
|
||||
value={filters.peopleCount}
|
||||
onChange={(e) => handleFilterChange('peopleCount', e.target.value)}
|
||||
>
|
||||
<option value="">{t('any')}</option>
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5+">5+</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label className="filter-label">
|
||||
<span className="filter-text">{t('priceRange')}:</span>
|
||||
<select
|
||||
className="filter-select"
|
||||
value={filters.priceRange}
|
||||
onChange={(e) => handleFilterChange('priceRange', e.target.value)}
|
||||
>
|
||||
<option value="">{t('any')}</option>
|
||||
<option value="€">€</option>
|
||||
<option value="€€">€€</option>
|
||||
<option value="€€€">€€€</option>
|
||||
<option value="€€€€">€€€€</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
84
frontend/src/utils/api.js
Normal file
84
frontend/src/utils/api.js
Normal file
@@ -0,0 +1,84 @@
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api';
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(message, status, data) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
const handleResponse = async (response) => {
|
||||
const contentType = response.headers.get('content-type');
|
||||
const isJson = contentType && contentType.includes('application/json');
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = isJson ? await response.json() : await response.text();
|
||||
throw new ApiError(
|
||||
errorData.message || `HTTP error! status: ${response.status}`,
|
||||
response.status,
|
||||
errorData
|
||||
);
|
||||
}
|
||||
|
||||
return isJson ? response.json() : response.text();
|
||||
};
|
||||
|
||||
export const api = {
|
||||
async get(endpoint, options = {}) {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
return handleResponse(response);
|
||||
},
|
||||
|
||||
async post(endpoint, data = {}, options = {}) {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
...options,
|
||||
});
|
||||
return handleResponse(response);
|
||||
},
|
||||
|
||||
async put(endpoint, data = {}, options = {}) {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
...options,
|
||||
});
|
||||
return handleResponse(response);
|
||||
},
|
||||
|
||||
async delete(endpoint, options = {}) {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
return handleResponse(response);
|
||||
},
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
7
frontend/vite.config.js
Normal file
7
frontend/vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user