diff --git a/Backend/app/database.py b/Backend/app/database.py index b27b60c9..4c435d90 100644 --- a/Backend/app/database.py +++ b/Backend/app/database.py @@ -56,7 +56,14 @@ def init_db(app): use_cloud_sql = os.getenv("USE_CLOUD_SQL", "true").lower() == "true" + print("\n" + "="*60) if use_cloud_sql: + instance_name = os.getenv("INSTANCE_CONNECTION_NAME", "N/A") + db_name = os.getenv("DB_NAME", "N/A") + print("🌐 DATABASE: Google Cloud SQL (MySQL)") + print(f" Instance: {instance_name}") + print(f" Database: {db_name}") + engine = connect_with_connector() app.config["SQLALCHEMY_DATABASE_URI"] = "mysql+pymysql://" app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {"creator": engine.raw_connection} @@ -65,7 +72,12 @@ def init_db(app): if not os.path.isabs(db_path): db_path = os.path.abspath(db_path) os.makedirs(os.path.dirname(db_path), exist_ok=True) + print("💾 DATABASE: Local SQLite") + print(f" Path: {db_path}") + app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}" + + print("="*60 + "\n") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db.init_app(app) diff --git a/Backend/app/schema.sql b/Backend/app/schema.sql deleted file mode 100644 index 2c49ef13..00000000 --- a/Backend/app/schema.sql +++ /dev/null @@ -1,13 +0,0 @@ --- clean old tables -DROP TABLE IF EXISTS users; - --- create users table -CREATE TABLE users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - email TEXT UNIQUE NOT NULL, - created_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP -); - --- insert test data -INSERT INTO users (username, email) VALUES ('test_user', 'test@example.com'); \ No newline at end of file diff --git a/Backend/app/tabs/__init__.py b/Backend/app/tabs/__init__.py index 621947f2..89150f93 100644 --- a/Backend/app/tabs/__init__.py +++ b/Backend/app/tabs/__init__.py @@ -1 +1,2 @@ # app/tabs/__init__.py +from app.tabs.model import Tab diff --git a/Backend/app/tabs/model.py b/Backend/app/tabs/model.py index 3335f432..f1134d13 100644 --- a/Backend/app/tabs/model.py +++ b/Backend/app/tabs/model.py @@ -1 +1,39 @@ -# app/tabs/model.py +import uuid +from typing import Optional + +from sqlalchemy import String, Integer, ForeignKey, JSON +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import db + + +class Tab(db.Model): + """Tab model using SQLAlchemy ORM""" + __tablename__ = 'tabs' + + # Primary key + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + # Foreign key + uid: Mapped[str] = mapped_column(String(36), ForeignKey('users.uid'), nullable=False, index=True) + + # Tab fields + tab_name: Mapped[str] = mapped_column(String(100), nullable=False) + map: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) # JSON field for map data + pin: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) # JSON field for pin data + + # Relationship + user: Mapped["User"] = relationship("User", back_populates="tabs") + + def __repr__(self) -> str: + return f"" + + def to_dict(self) -> dict: + """Convert model to dictionary""" + return { + 'id': self.id, + 'uid': self.uid, + 'tab_name': self.tab_name, + 'map': self.map, + 'pin': self.pin, + } diff --git a/Backend/app/tabs/schema.py b/Backend/app/tabs/schema.py index 3b8e8b6e..0a1955d4 100644 --- a/Backend/app/tabs/schema.py +++ b/Backend/app/tabs/schema.py @@ -1 +1,36 @@ -# app/tabs/schema.py +from typing import Optional, Dict, Any +from pydantic import BaseModel + + +class TabBase(BaseModel): + """Base tab schema""" + tab_name: str + map: Optional[Dict[str, Any]] = None + pin: Optional[Dict[str, Any]] = None + + +class TabCreate(TabBase): + """Schema for creating a tab""" + pass + + +class TabUpdate(BaseModel): + """Schema for updating a tab (all fields optional)""" + tab_name: Optional[str] = None + map: Optional[Dict[str, Any]] = None + pin: Optional[Dict[str, Any]] = None + + +class TabResponse(TabBase): + """Schema for tab response""" + id: int + uid: str + + class Config: + from_attributes = True + + +class TabListResponse(BaseModel): + """Schema for list of tabs""" + tabs: list[TabResponse] + total: int diff --git a/Backend/app/user/__init__.py b/Backend/app/user/__init__.py index 06ff6004..d5bcdfb2 100644 --- a/Backend/app/user/__init__.py +++ b/Backend/app/user/__init__.py @@ -1 +1,2 @@ # User module +from app.user.model import User, UserProfile diff --git a/Backend/app/user/controller.py b/Backend/app/user/controller.py index 0c247971..1611abf0 100644 --- a/Backend/app/user/controller.py +++ b/Backend/app/user/controller.py @@ -19,20 +19,20 @@ def get(self): -@api.route('/users/') +@api.route('/users/') class UserApi(Resource): - def put(self, user_id): + def put(self, uid): """Update user by ID""" data = request.get_json() - username = data.get('username') + name = data.get('name') email = data.get('email') password = data.get('password') try: - user = user_service.update_user(user_id, username, email, password) + user = user_service.update_user(uid, name, email, password) if not user: - api.abort(404, f'User {user_id} not found') + api.abort(404, f'User {uid} not found') return { 'success': True, @@ -40,18 +40,16 @@ def put(self, user_id): 'user': to_dict(user) } except IntegrityError as e: - if 'UNIQUE constraint failed: users.email' in str(e): + if 'UNIQUE constraint failed: users.email' in str(e) or 'Duplicate entry' in str(e): api.abort(400, 'Email already exists') - elif 'UNIQUE constraint failed: users.username' in str(e): - api.abort(400, 'Username already exists') else: api.abort(400, 'Database constraint violation') - def delete(self, user_id): + def delete(self, uid): """Delete user by ID""" - success = user_service.delete_user(user_id) + success = user_service.delete_user(uid) if not success: - api.abort(404, f'User {user_id} not found') + api.abort(404, f'User {uid} not found') return { 'success': True, @@ -65,12 +63,15 @@ def post(self): """Create a new user""" data = request.get_json() - username = data.get('username') + name = data.get('name') email = data.get('email') password = data.get('password') + if not name or not email or not password: + api.abort(400, 'Name, email and password are required') + try: - user = user_service.create_user(username, email, password) + user = user_service.create_user(name, email, password) return { 'success': True, 'message': 'User created successfully', @@ -78,10 +79,8 @@ def post(self): }, 201 except IntegrityError as e: # handle unique constraint error - if 'UNIQUE constraint failed: users.email' in str(e): + if 'UNIQUE constraint failed: users.email' in str(e) or 'Duplicate entry' in str(e): api.abort(400, 'Email already exists') - elif 'UNIQUE constraint failed: users.username' in str(e): - api.abort(400, 'Username already exists') else: api.abort(400, 'Database constraint violation') @@ -92,11 +91,13 @@ def post(self): """User login""" data = request.get_json() - username = data.get('username') email = data.get('email') password = data.get('password') - user = user_service.authenticate_user(username, email, password) + if not email or not password: + api.abort(400, 'Email and password are required') + + user = user_service.authenticate_user(email, password) if not user: api.abort(401, 'Invalid credentials') @@ -123,7 +124,7 @@ def get(self): """Get current user profile (dummy)""" return { 'user': { - 'username': 'dummy_user', + 'name': 'dummy_user', 'email': 'dummy@example.com' } } @@ -134,7 +135,7 @@ class MySettingApi(Resource): def get(self): """Get current user's setting (dummy)""" return { - 'user_id': 'me', + 'uid': 'me', 'settings': { 'theme': 'light', 'language': 'en-US' @@ -146,7 +147,7 @@ def put(self): data = request.get_json() or {} return { 'success': True, - 'user_id': 'me', + 'uid': 'me', 'updated_settings': data } diff --git a/Backend/app/user/model.py b/Backend/app/user/model.py index 00de4c57..2c3b0cb5 100644 --- a/Backend/app/user/model.py +++ b/Backend/app/user/model.py @@ -3,8 +3,8 @@ from datetime import datetime from typing import Optional -from sqlalchemy import String, DateTime -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import String, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import db @@ -14,34 +14,61 @@ class User(db.Model): __tablename__ = 'users' # Primary key - user_id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + uid: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) # User fields - username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) password: Mapped[str] = mapped_column(String(255), nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + + # Relationships + profile: Mapped[Optional["UserProfile"]] = relationship("UserProfile", back_populates="user", uselist=False, cascade="all, delete-orphan") + tabs: Mapped[list["Tab"]] = relationship("Tab", back_populates="user", cascade="all, delete-orphan") def __repr__(self) -> str: - return f"" + return f"" def set_password(self, password: str) -> None: """Hash and set password""" - # salt = bcrypt.gensalt() - # self.password = bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8') - self.password = password + salt = bcrypt.gensalt() + self.password = bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8') def check_password(self, password: str) -> bool: """Check if provided password matches stored hash""" - # return bcrypt.checkpw(password.encode('utf-8'), self.password.encode('utf-8')) - return self.password == password + return bcrypt.checkpw(password.encode('utf-8'), self.password.encode('utf-8')) def to_dict(self) -> dict: - """Convert model to dictionary""" + """Convert model to dictionary (exclude password)""" return { - 'user_id': self.user_id, - 'username': self.username, + 'uid': self.uid, + 'name': self.name, 'email': self.email, - 'password': self.password, # WARNING: don't return password in production - 'created_at': self.created_at.isoformat() if self.created_at else None + } + + +class UserProfile(db.Model): + """User profile model""" + __tablename__ = 'user_profiles' + + # Primary key + uid: Mapped[str] = mapped_column(String(36), ForeignKey('users.uid'), primary_key=True) + + # Profile fields + color: Mapped[Optional[str]] = mapped_column(String(1000), nullable=True) # JSON string of list + favour_tabs: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) # tab id reference + pic: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # picture URL or path + + # Relationship + user: Mapped["User"] = relationship("User", back_populates="profile") + + def __repr__(self) -> str: + return f"" + + def to_dict(self) -> dict: + """Convert model to dictionary""" + return { + 'uid': self.uid, + 'color': self.color, + 'favour_tabs': self.favour_tabs, + 'pic': self.pic, } \ No newline at end of file diff --git a/Backend/app/user/schema.py b/Backend/app/user/schema.py index 9ce07729..55bbe310 100644 --- a/Backend/app/user/schema.py +++ b/Backend/app/user/schema.py @@ -1,15 +1,62 @@ -from datetime import datetime -from typing import Optional - +from typing import Optional, List from pydantic import BaseModel -class UserSchema(BaseModel): - """Basic user schema""" - user_id: str - username: str +class UserBase(BaseModel): + """Base user schema""" + name: str + email: str + + +class UserCreate(UserBase): + """Schema for creating a user""" + password: str + + +class UserLogin(BaseModel): + """Schema for user login""" email: str - created_at: datetime + password: str + + +class UserResponse(UserBase): + """Schema for user response (no password)""" + uid: str + + class Config: + from_attributes = True + + +class UserProfileBase(BaseModel): + """Base user profile schema""" + color: Optional[List[str]] = None + favour_tabs: Optional[str] = None + pic: Optional[str] = None + + +class UserProfileCreate(UserProfileBase): + """Schema for creating user profile""" + pass + + +class UserProfileUpdate(BaseModel): + """Schema for updating user profile (all fields optional)""" + color: Optional[List[str]] = None + favour_tabs: Optional[str] = None + pic: Optional[str] = None + + +class UserProfileResponse(UserProfileBase): + """Schema for user profile response""" + uid: str + + class Config: + from_attributes = True + + +class UserWithProfile(UserResponse): + """Schema for user with profile""" + profile: Optional[UserProfileResponse] = None class Config: - from_attributes = True \ No newline at end of file + from_attributes = True diff --git a/Backend/app/user/service.py b/Backend/app/user/service.py index e7adaff3..d572616d 100644 --- a/Backend/app/user/service.py +++ b/Backend/app/user/service.py @@ -7,9 +7,9 @@ class UserService: """User service layer for business logic""" - def create_user(self, username: str, email: str, password: str) -> User: + def create_user(self, name: str, email: str, password: str) -> User: """Create a new user""" - user = User(username=username, email=email) + user = User(name=name, email=email) user.set_password(password) db.session.add(user) @@ -17,13 +17,9 @@ def create_user(self, username: str, email: str, password: str) -> User: return user - def get_user_by_id(self, user_id: str) -> Optional[User]: + def get_user_by_id(self, uid: str) -> Optional[User]: """Get user by ID""" - return db.session.get(User, user_id) - - def get_user_by_username(self, username: str) -> Optional[User]: - """Get user by username""" - return db.session.query(User).filter_by(username=username).first() + return db.session.get(User, uid) def get_user_by_email(self, email: str) -> Optional[User]: """Get user by email""" @@ -33,30 +29,26 @@ def get_all_users(self) -> List[User]: """Get all users""" return db.session.query(User).all() - def authenticate_user(self, username: str = None, email: str = None, password: str = None) -> Optional[User]: - """Authenticate user by username or email and password""" - if not password: + def authenticate_user(self, email: str = None, password: str = None) -> Optional[User]: + """Authenticate user by email and password""" + if not password or not email: return None - user = None - if username: - user = self.get_user_by_username(username) - elif email: - user = self.get_user_by_email(email) + user = self.get_user_by_email(email) if user and user.check_password(password): return user return None - def update_user(self, user_id: str, username: str = None, email: str = None, password: str = None) -> Optional[User]: + def update_user(self, uid: str, name: str = None, email: str = None, password: str = None) -> Optional[User]: """Update user information""" - user = self.get_user_by_id(user_id) + user = self.get_user_by_id(uid) if not user: return None - if username: - user.username = username + if name: + user.name = name if email: user.email = email if password: @@ -65,9 +57,9 @@ def update_user(self, user_id: str, username: str = None, email: str = None, pas db.session.commit() return user - def delete_user(self, user_id: str) -> bool: + def delete_user(self, uid: str) -> bool: """Delete user by ID""" - user = self.get_user_by_id(user_id) + user = self.get_user_by_id(uid) if not user: return False diff --git a/Frontend/API_STRUCTURE.md b/Frontend/API_STRUCTURE.md deleted file mode 100644 index 536934f9..00000000 --- a/Frontend/API_STRUCTURE.md +++ /dev/null @@ -1,372 +0,0 @@ -# Frontend API Structure 📁 - -Clean, maintainable API architecture for the WeatherJYJAM frontend. - -## 🎯 Quick Start - -### Switch Between Local and Production - -Edit **ONE line** in `src/api/config.ts`: - -```typescript -// For local development -const USE_LOCAL = true - -// For production -const USE_LOCAL = false -``` - -That's it! All API calls will automatically use the correct URL. - ---- - -## 📂 File Structure - -``` -Frontend/src/ -├── api/ -│ ├── config.ts # API configuration & URLs -│ ├── search.ts # Search & AI search functions -│ ├── weather.ts # Weather data functions -│ └── index.ts # Export all APIs -│ -└── _components/Header/SearchBar/ - ├── _hooks/ - │ ├── useSearch.ts # Search logic hook - │ ├── useAISearch.ts # AI search logic hook - │ └── index.ts # Export hooks - │ - ├── SearchBar.tsx # Main component (clean!) - ├── SearchDropdown.tsx # Station list (uses useSearch) - └── AIdropdown.tsx # AI response (uses useAISearch) -``` - ---- - -## 🔧 API Configuration (`src/api/config.ts`) - -**Centralized URL management:** - -```typescript -const USE_LOCAL = false // ← Toggle this - -const LOCAL_API_URL = 'http://127.0.0.1:2333' -const PRODUCTION_API_URL = 'https://weatherjyjam-production.up.railway.app' - -export const API_BASE_URL = USE_LOCAL ? LOCAL_API_URL : PRODUCTION_API_URL -``` - -**All endpoints defined in one place:** - -```typescript -export const API_ENDPOINTS = { - search: `${API_BASE_URL}/api/search`, - searchAI: `${API_BASE_URL}/api/search/ai`, - weatherNearest: `${API_BASE_URL}/api/weather/nearest`, - weatherByStation: (stationName: string) => - `${API_BASE_URL}/api/weather/avg_${encodeURIComponent(stationName)}`, -} -``` - ---- - -## 🔍 Search API (`src/api/search.ts`) - -### Search Stations - -```typescript -import { searchStations } from '@/api' - -const data = await searchStations('melbourne') -// Returns: { query: 'melbourne', results: [...] } -``` - -### Stream AI Response - -```typescript -import { streamAISearch } from '@/api' - -await streamAISearch( - 'What is the weather in Sydney?', - (text) => console.log('AI:', text), // Called on each chunk - abortSignal, // Optional: for cancellation -) -``` - ---- - -## 🌤️ Weather API (`src/api/weather.ts`) - -### Get Nearest Station - -```typescript -import { getNearestStation } from '@/api' - -const station = await getNearestStation(-37.8136, 144.9631) -// Returns: { status: 'success', data: { 'Station Name': '...' } } -``` - -### Get Weather Data - -```typescript -import { getWeatherByStation } from '@/api' - -const weather = await getWeatherByStation('MELBOURNE AIRPORT') -// Returns: Array of weather entries -``` - -### All-in-One - -```typescript -import { getWeatherForLocation } from '@/api' - -const { stationName, weatherData } = await getWeatherForLocation( - -37.8136, - 144.9631, -) -``` - ---- - -## 🪝 Custom Hooks - -### `useSearch` Hook - -**Location:** `src/_components/Header/SearchBar/_hooks/useSearch.ts` - -```typescript -import { useSearch } from './_hooks' - -const { results, loading, error } = useSearch(query) -``` - -**Features:** - -- ✅ Auto debounce (300ms) -- ✅ Loading states -- ✅ Error handling -- ✅ Empty query handling - -### `useAISearch` Hook - -**Location:** `src/_components/Header/SearchBar/_hooks/useAISearch.ts` - -```typescript -import { useAISearch } from './_hooks' - -const { aiResponse, isStreaming } = useAISearch({ - prompt: 'What is the weather?', - aiState: 'loading', - onStreamComplete: () => console.log('Done!'), -}) -``` - -**Features:** - -- ✅ SSE streaming -- ✅ Auto cleanup -- ✅ Abort controller -- ✅ Real-time updates - ---- - -## 📦 Component Examples - -### Before Refactor ❌ - -```typescript -// Hard-coded URL in component -const response = await fetch('http://127.0.0.1:2333/api/search?q=...') - -// Logic mixed with UI -const [results, setResults] = useState([]) -useEffect(() => { - // 50 lines of fetch logic... -}, [query]) -``` - -### After Refactor ✅ - -```typescript -// Clean import -import { useSearch } from './_hooks' - -// One line! -const { results, loading, error } = useSearch(query) -``` - ---- - -## 🎨 Benefits - -### 1. **Single Source of Truth** - -Change `USE_LOCAL` once → all API calls update - -### 2. **Separation of Concerns** - -- Components → UI only -- Hooks → Logic -- API → Data fetching - -### 3. **Reusability** - -```typescript -// Use anywhere in the app -import { searchStations, getWeatherByStation } from '@/api' -``` - -### 4. **Type Safety** - -```typescript -export interface SearchResult { - query: string - results: string[] -} -``` - -### 5. **Easy Testing** - -Mock the API layer: - -```typescript -jest.mock('@/api', () => ({ - searchStations: jest.fn(() => Promise.resolve({ results: [...] })) -})) -``` - ---- - -## 🚀 Usage in Components - -### SearchDropdown.tsx - -**Before:** 85 lines (fetch logic + UI) -**After:** 50 lines (UI only) - -```typescript -import { useSearch } from './_hooks' - -const { results, loading, error } = useSearch(query) -``` - -### AIdropdown.tsx - -**Before:** 145 lines (SSE logic + UI) -**After:** 90 lines (UI only) - -```typescript -import { useAISearch } from './_hooks' - -const { aiResponse, isStreaming } = useAISearch({ - prompt, - aiState, - onStreamComplete, -}) -``` - -### Details.tsx - -**Before:** Hard-coded URLs -**After:** Clean API calls - -```typescript -import { getWeatherForLocation } from '@/api' - -const { weatherData } = await getWeatherForLocation(lat, lng) -``` - ---- - -## 🔄 Migration Guide - -### Old Code - -```typescript -const response = await fetch( - `https://weatherjyjam-production.up.railway.app/api/search?q=${query}`, -) -const data = await response.json() -``` - -### New Code - -```typescript -import { searchStations } from '@/api' - -const data = await searchStations(query) -``` - ---- - -## 📊 File Size Comparison - -| Component | Before | After | Reduction | -| -------------- | ---------- | ------------- | --------- | -| SearchDropdown | 135 lines | 95 lines | **-30%** | -| AIdropdown | 196 lines | 105 lines | **-46%** | -| Details.tsx | Hard-coded | Clean imports | ✅ | - ---- - -## 🎯 Best Practices - -### ✅ DO - -```typescript -import { searchStations } from '@/api' -import { useSearch } from './_hooks' -``` - -### ❌ DON'T - -```typescript -const url = 'http://127.0.0.1:2333/api/search' // Hard-coded -fetch(url + '?q=' + query) // Manual URL building -``` - ---- - -## 🛠️ Adding New Endpoints - -### 1. Add to `api/config.ts` - -```typescript -export const API_ENDPOINTS = { - // ... existing endpoints - newEndpoint: `${API_BASE_URL}/api/new`, -} -``` - -### 2. Create function in `api/.ts` - -```typescript -export const fetchNewData = async () => { - const response = await fetch(API_ENDPOINTS.newEndpoint) - return response.json() -} -``` - -### 3. Export in `api/index.ts` - -```typescript -export * from './new-domain' -``` - -### 4. Use anywhere - -```typescript -import { fetchNewData } from '@/api' -``` - ---- - -## 🎉 Summary - -✅ **ONE place** to switch local/production -✅ **Clean components** with separated logic -✅ **Reusable hooks** for common patterns -✅ **Type-safe** API calls -✅ **Easy to test** and maintain - -Now your codebase is production-ready! 🚀 diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index a833da66..034868ba 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -9,24 +9,27 @@ import { TabsProvider, PinProvider, ControlPanelProvider, + AuthProvider, } from './_components/ContextHooks' import { TabsPinIntegration } from './_components/ContextHooks/TabsPinIntegration' // Re-enabled with fixes const App: FC = () => ( - - - - - - } /> - } /> - } /> - } /> - } /> - - - - + + + + + + + } /> + } /> + } /> + } /> + } /> + + + + + ) export default App diff --git a/Frontend/src/_components/ContextHooks/AuthContext.tsx b/Frontend/src/_components/ContextHooks/AuthContext.tsx new file mode 100644 index 00000000..29309e8a --- /dev/null +++ b/Frontend/src/_components/ContextHooks/AuthContext.tsx @@ -0,0 +1,34 @@ +import { useState, useCallback, type ReactNode } from 'react' +import { AuthContext, type User } from './authContext' + +interface AuthProviderProps { + children: ReactNode +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [isLoggedIn, setIsLoggedIn] = useState(false) + const [user, setUser] = useState(null) + + const login = useCallback((userData: User) => { + setUser(userData) + setIsLoggedIn(true) + }, []) + + const logout = useCallback(() => { + setUser(null) + setIsLoggedIn(false) + }, []) + + return ( + + {children} + + ) +} diff --git a/Frontend/src/_components/ContextHooks/PinContext.tsx b/Frontend/src/_components/ContextHooks/PinContext.tsx index dbbc11ec..c7afbe83 100644 --- a/Frontend/src/_components/ContextHooks/PinContext.tsx +++ b/Frontend/src/_components/ContextHooks/PinContext.tsx @@ -150,60 +150,76 @@ export const PinProvider: React.FC = ({ children }) => { console.log('Adding pin at:', position) // Debug log + // Create initial pin with loading state immediately + const loadingPin: PinData = { + id: pinId, + position, + locationName: 'Loading...', + weatherData: { + temperature: 0, + windSpeed: 0, + humidity: 0, + description: 'Loading...', + isLoading: true, + }, + } + + // Immediately place the pin (this shows the pin and bottomsheet instantly) + if (!locationOnePin) { + console.log('Setting as location one pin (loading)') // Debug log + setLocationOnePin(loadingPin) + } else if (!locationTwoPin) { + console.log('Setting as location two pin (loading)') // Debug log + setLocationTwoPin(loadingPin) + } else { + console.log('Shifting pins - new pin to location two (loading)') // Debug log + setLocationOnePin(locationTwoPin) + setLocationTwoPin(loadingPin) + } + + // Fetch data in the background and update the pin try { - // Get location name from OpenStreetMap and weather data from OpenWeatherMap const [locationName, weatherData] = await Promise.all([ getLocationName(position.lat, position.lng), getWeatherData(position.lat, position.lng), ]) - const newPin: PinData = { + // Update the pin with actual data + const updatedPin: PinData = { id: pinId, - position, // Keep the original LatLng object + position, locationName, - weatherData, + weatherData: { + ...weatherData, + isLoading: false, + }, } - console.log('Created new pin:', newPin) // Debug log - - if (!locationOnePin) { - console.log('Setting as location one pin') // Debug log - setLocationOnePin(newPin) - } else if (!locationTwoPin) { - console.log('Setting as location two pin') // Debug log - setLocationTwoPin(newPin) - } else { - console.log( - 'Shifting pins - moving location two to one, new pin to two', - ) // Debug log - // Both slots filled, shift locations - setLocationOnePin(locationTwoPin) - setLocationTwoPin(newPin) - } + console.log('Updated pin with data:', updatedPin) // Debug log + + // Use functional updates to ensure we're working with the latest state + setLocationOnePin((prev) => (prev?.id === pinId ? updatedPin : prev)) + setLocationTwoPin((prev) => (prev?.id === pinId ? updatedPin : prev)) } catch (error) { - console.error('Error adding pin:', error) + console.error('Error loading pin data:', error) - // Create pin with fallback data - const fallbackPin: PinData = { + // Update with error state + const errorPin: PinData = { id: pinId, - position, // Keep the original LatLng object + position, locationName: `Location ${position.lat.toFixed(2)}, ${position.lng.toFixed(2)}`, weatherData: { temperature: 0, windSpeed: 0, humidity: 0, - description: 'Data unavailable', + description: 'Failed to load data', + isLoading: false, }, } - if (!locationOnePin) { - setLocationOnePin(fallbackPin) - } else if (!locationTwoPin) { - setLocationTwoPin(fallbackPin) - } else { - setLocationOnePin(locationTwoPin) - setLocationTwoPin(fallbackPin) - } + // Use functional updates for error state + setLocationOnePin((prev) => (prev?.id === pinId ? errorPin : prev)) + setLocationTwoPin((prev) => (prev?.id === pinId ? errorPin : prev)) } } diff --git a/Frontend/src/_components/ContextHooks/authContext.ts b/Frontend/src/_components/ContextHooks/authContext.ts new file mode 100644 index 00000000..95bd1356 --- /dev/null +++ b/Frontend/src/_components/ContextHooks/authContext.ts @@ -0,0 +1,16 @@ +import { createContext } from 'react' + +export interface User { + id: string + email: string + name?: string +} + +export interface AuthContextType { + isLoggedIn: boolean + user: User | null + login: (user: User) => void + logout: () => void +} + +export const AuthContext = createContext(null) diff --git a/Frontend/src/_components/ContextHooks/contexts.tsx b/Frontend/src/_components/ContextHooks/contexts.tsx index 2059dc9e..6e87ae87 100644 --- a/Frontend/src/_components/ContextHooks/contexts.tsx +++ b/Frontend/src/_components/ContextHooks/contexts.tsx @@ -6,6 +6,7 @@ export interface WeatherData { windSpeed: number humidity: number description: string + isLoading?: boolean } export interface PinData { diff --git a/Frontend/src/_components/ContextHooks/hooks.tsx b/Frontend/src/_components/ContextHooks/hooks.tsx index fd91c357..d4c84491 100644 --- a/Frontend/src/_components/ContextHooks/hooks.tsx +++ b/Frontend/src/_components/ContextHooks/hooks.tsx @@ -2,3 +2,4 @@ export { usePinContext } from './usePinContext' export { useControlPanelContext } from './useControlPanelContext' export { useTabsContext } from './useTabsContext' +export { useAuthContext } from './useAuthContext' diff --git a/Frontend/src/_components/ContextHooks/index.tsx b/Frontend/src/_components/ContextHooks/index.tsx index 623f6b13..8661e2aa 100644 --- a/Frontend/src/_components/ContextHooks/index.tsx +++ b/Frontend/src/_components/ContextHooks/index.tsx @@ -2,3 +2,4 @@ export { PinProvider } from './PinContext' export { ControlPanelProvider } from './ControlPanelContext' export { TabsProvider } from './TabsContext' +export { AuthProvider } from './AuthContext' diff --git a/Frontend/src/_components/ContextHooks/useAuthContext.tsx b/Frontend/src/_components/ContextHooks/useAuthContext.tsx new file mode 100644 index 00000000..50919bcb --- /dev/null +++ b/Frontend/src/_components/ContextHooks/useAuthContext.tsx @@ -0,0 +1,10 @@ +import { useContext } from 'react' +import { AuthContext, type AuthContextType } from './authContext' + +export const useAuthContext = (): AuthContextType => { + const context = useContext(AuthContext) + if (!context) { + throw new Error('useAuthContext must be used within an AuthProvider') + } + return context +} diff --git a/Frontend/src/_components/Header/Menu/Menu.tsx b/Frontend/src/_components/Header/Menu/Menu.tsx index e43a9bd4..ca43aad9 100644 --- a/Frontend/src/_components/Header/Menu/Menu.tsx +++ b/Frontend/src/_components/Header/Menu/Menu.tsx @@ -9,7 +9,7 @@ const ButtonContainer = styled.div` position: fixed; top: 20px; right: 20px; - z-index: 900; + z-index: 10000; display: flex; align-items: center; gap: 12px; diff --git a/Frontend/src/_components/Header/Menu/login/LoginMenu.tsx b/Frontend/src/_components/Header/Menu/login/LoginMenu.tsx index a0080e80..465c1ff0 100644 --- a/Frontend/src/_components/Header/Menu/login/LoginMenu.tsx +++ b/Frontend/src/_components/Header/Menu/login/LoginMenu.tsx @@ -1,6 +1,7 @@ import type { FC } from 'react' import { Link, useLocation } from 'react-router-dom' import styled from 'styled-components' +import { useAuthContext } from '../../../ContextHooks/hooks' const MenuItemContainer = styled.div` background-color: white; @@ -30,31 +31,80 @@ const MenuItemText = styled(Link)<{ $isActive: boolean }>` } ` +const MenuButton = styled.button<{ $isActive?: boolean }>` + display: block; + width: 100%; + font-family: 'Instrument Sans', sans-serif; + border-radius: 10px; + padding: 16px 12px; + text-decoration: none; + color: #333; + cursor: pointer; + border: none; + background-color: ${({ $isActive }) => + $isActive ? '#def8ffff' : 'transparent'}; + text-align: left; + &:hover { + background-color: #def8ffff; + transform: scale(1.02); + &:active { + transform: scale(0.98); + } + } +` + interface LoginMenuProps { onItemClick: () => void } const LoginMenu: FC = ({ onItemClick }) => { const location = useLocation() - const loginItems = [ - { to: '/profile', label: 'Profile' }, - { to: '/login', label: 'Login' }, - { to: '/signup', label: 'Sign Up' }, - ] + const { isLoggedIn, logout } = useAuthContext() - return ( - <> - {loginItems.map((item) => ( - + const handleLogout = () => { + logout() + onItemClick() + } + + if (isLoggedIn) { + return ( + <> + - {item.label} + Profile - ))} + + Logout + + + ) + } + + return ( + <> + + + Login + + + + + Register + + ) } diff --git a/Frontend/src/api/config.ts b/Frontend/src/api/config.ts index 923adbb9..ed37249e 100644 --- a/Frontend/src/api/config.ts +++ b/Frontend/src/api/config.ts @@ -4,7 +4,7 @@ */ // Toggle between local development and production -const USE_LOCAL = false // Set to true for local development +const USE_LOCAL = true // Set to true for local development const LOCAL_API_URL = 'http://127.0.0.1:2333' const PRODUCTION_API_URL = 'https://weatherjyjam-production.up.railway.app' @@ -25,4 +25,3 @@ export const API_ENDPOINTS = { user: `${API_BASE_URL}/api/user`, me: `${API_BASE_URL}/api/me`, } as const - diff --git a/Frontend/src/pages/Home/_components/BottomSheet/BottomSheet.tsx b/Frontend/src/pages/Home/_components/BottomSheet/BottomSheet.tsx index 443b410a..8b1f973a 100644 --- a/Frontend/src/pages/Home/_components/BottomSheet/BottomSheet.tsx +++ b/Frontend/src/pages/Home/_components/BottomSheet/BottomSheet.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import { styled } from 'styled-components' import useBottomSheet from './_hooks/useBottomSheet' import WeatherStats from './WeatherStats' +import { usePinContext } from '@/_components/ContextHooks/usePinContext' const BottomSheetWrapper = styled.div` position: absolute; @@ -62,6 +63,14 @@ const ScrollableContent = styled.div` const BottomSheet: FC = () => { const { isOpen, toggle } = useBottomSheet(false) + const { locationOnePin, locationTwoPin } = usePinContext() + + // Don't show bottom sheet if no pins are selected + const hasAnyPin = locationOnePin !== null || locationTwoPin !== null + + if (!hasAnyPin) { + return null + } return ( diff --git a/Frontend/src/pages/Home/_components/BottomSheet/WeatherStats.tsx b/Frontend/src/pages/Home/_components/BottomSheet/WeatherStats.tsx index b129638b..96374eb9 100644 --- a/Frontend/src/pages/Home/_components/BottomSheet/WeatherStats.tsx +++ b/Frontend/src/pages/Home/_components/BottomSheet/WeatherStats.tsx @@ -1,6 +1,5 @@ import type { FC } from 'react' import { styled } from 'styled-components' -import { Dropdown } from '@/_components' import { TemperatureBar, WindBar, HumidityBar } from '@/_components' import { useNavigate } from 'react-router-dom' import { usePinContext } from '@/_components/ContextHooks/usePinContext' @@ -8,7 +7,7 @@ import { useControlPanelContext } from '@/_components/ContextHooks/useControlPan import type { WeatherData } from '@/_components/ContextHooks/contexts' const Spacer = styled.div` - height: 40px; + height: 10px; ` const HeaderRow = styled.div` display: flex; @@ -38,70 +37,19 @@ const BarsRow = styled.div` gap: 12px; ` -const PastGrid = styled.div` - display: grid; - font-family: 'Instrument Sans', sans-serif; - grid-template-columns: 1.3fr 1fr; - gap: 20px; - - @media (max-width: 900px) { - grid-template-columns: 1fr; - } -` - const SectionTitle = styled.h4` margin: 4px 0; font-size: 16px; font-family: 'Instrument Sans', sans-serif; ` -const Filters = styled.div` - display: flex; - align-items: center; - gap: 10px; - margin: 6px 0 2px; -` - -const FilterButton = styled.button` - height: 36px; - font-family: 'Instrument Sans', sans-serif; - padding: 0 12px; - border-radius: 8px; - border: 1px solid #ddd; - background: #f6f7f9; - color: #23272a; - cursor: pointer; - font-size: 14px; -` - -const Menu = styled.ul` - list-style: none; - font-family: 'Instrument Sans', sans-serif; - margin: 0; - padding: 0; - display: grid; - gap: 6px; -` - -const MenuItem = styled.li` - font-size: 14px; - font-family: 'Instrument Sans', sans-serif; - padding: 6px 8px; - border-radius: 6px; - cursor: default; - - &:hover { - background: #f3f3f7; - } -` - const GraphButton = styled.button<{ tall?: boolean }>` display: grid; place-items: center; padding: 8px; border: 1px solid #007acc; border-radius: 10px; - min-height: ${(props) => (props.tall ? '120px' : '220px')}; + min-height: ${(props) => (props.tall ? '10px' : '20px')}; color: #007acc; background-color: #f6fcff; font-size: 14px; @@ -124,14 +72,6 @@ const TwoColumn = styled.div` gap: 16px; } ` -const PastTwoColumn = styled.div` - display: flex; - gap: 32px; - @media (max-width: 900px) { - flex-direction: column; - gap: 16px; - } -` const NoDataMessage = styled.div` display: flex; align-items: flex-start; @@ -143,6 +83,36 @@ const NoDataMessage = styled.div` font-size: 16px; ` +const LoadingSpinner = styled.div` + display: inline-block; + width: 16px; + height: 16px; + border: 3px solid #f3f3f3; + border-top: 3px solid #007acc; + border-radius: 50%; + animation: spin 1s linear infinite; + + @keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } +` + +const LoadingContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #666; + font-family: 'Instrument Sans', sans-serif; + font-size: 14px; +` + interface WeatherStatsProps { isExpanded?: boolean } @@ -163,22 +133,36 @@ const WeatherStats: FC = ({ isExpanded = true }) => { const barStyle = getBarStyle() // Helper function to render weather bars with individual styling - const renderWeatherBars = (weatherData: WeatherData) => ( - <> - - - - - ) + const renderWeatherBars = (weatherData: WeatherData) => { + if (weatherData.isLoading) { + return ( + + + Loading weather data... + + ) + } + + return ( + <> + + + + + ) + } return ( <> @@ -221,59 +205,15 @@ const WeatherStats: FC = ({ isExpanded = true }) => { - +
Past Weather Report - - - ( - Year - )} - > - - 2024 - 2023 - 2022 - - - - ( - Month - )} - > - - January - February - March - April - May - June - July - August - September - October - November - December - - - - - - {hasLocationOne && - locationOnePin?.weatherData && - renderWeatherBars(locationOnePin.weatherData)} - {hasLocationTwo && - locationTwoPin?.weatherData && - renderWeatherBars(locationTwoPin.weatherData)} - - - + Click to view detailed graphs - +
)} @@ -322,79 +262,7 @@ const WeatherStats: FC = ({ isExpanded = true }) => { > Past Weather Report - - {/* Location 1 Past */} -
- - ( - Year - )} - > - - 2024 - 2023 - 2022 - - - ( - Month - )} - > - - January - February - March - - - - {locationOnePin.weatherData && ( - - {renderWeatherBars(locationOnePin.weatherData)} - - )} -
- - {/* Location 2 Past */} -
- - ( - Year - )} - > - - 2024 - 2023 - 2022 - - - ( - Month - )} - > - - January - February - March - - - - {locationTwoPin.weatherData && ( - - {renderWeatherBars(locationTwoPin.weatherData)} - - )} -
-
diff --git a/Frontend/src/pages/Home/_components/Dashboard/MapView/Map.tsx b/Frontend/src/pages/Home/_components/Dashboard/MapView/Map.tsx index c576e30b..8089e3c6 100644 --- a/Frontend/src/pages/Home/_components/Dashboard/MapView/Map.tsx +++ b/Frontend/src/pages/Home/_components/Dashboard/MapView/Map.tsx @@ -1,10 +1,11 @@ import type { FC } from 'react' import { useEffect, useRef } from 'react' import styled from 'styled-components' -import { MapContainer, useMap, useMapEvents } from 'react-leaflet' +import { MapContainer, ScaleControl, useMap, useMapEvents } from 'react-leaflet' import type { LatLngExpression, LatLngBoundsExpression } from 'leaflet' import WeatherLayers from './WeatherLayers' import MapPins from './MapPins' +import MapLegend from './MapLegend' import { useControlPanelContext } from '@/_components/ContextHooks/useControlPanelContext' import { useTabsContext } from '@/_components/ContextHooks/useTabsContext' @@ -222,8 +223,10 @@ const Map: FC = () => { > + + ) diff --git a/Frontend/src/pages/Home/_components/Dashboard/MapView/MapLegend.tsx b/Frontend/src/pages/Home/_components/Dashboard/MapView/MapLegend.tsx new file mode 100644 index 00000000..4ffe96a3 --- /dev/null +++ b/Frontend/src/pages/Home/_components/Dashboard/MapView/MapLegend.tsx @@ -0,0 +1,358 @@ +import { useEffect, useState } from 'react' +import { useMap } from 'react-leaflet' +import L from 'leaflet' +import styled from 'styled-components' +import { createRoot } from 'react-dom/client' +import { useControlPanelContext } from '@/_components/ContextHooks/useControlPanelContext' + +const LegendContainer = styled.div<{ isCollapsed: boolean }>` + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + font-family: 'Instrument Sans', sans-serif; + transition: all 0.3s ease; + overflow: hidden; + ${({ isCollapsed }) => + isCollapsed + ? ` + width: auto; + height: auto; + ` + : ` + padding: 12px; + min-width: 200px; + max-width: 250px; + `} +` + +const ToggleButton = styled.button` + background: transparent; + border: none; + cursor: pointer; + font-size: 14px; + padding: 8px 12px; + display: flex; + align-items: center; + gap: 6px; + color: #333; + white-space: nowrap; + + &:hover { + color: #007acc; + } +` + +const LegendContent = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +` + +const LegendHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +` + +const LegendTitle = styled.h4` + margin: 0; + font-size: 14px; + font-weight: 600; + color: #333; +` + +const CollapseButton = styled.button` + background: transparent; + border: none; + cursor: pointer; + font-size: 16px; + padding: 4px; + color: #666; + line-height: 1; + + &:hover { + color: #007acc; + } +` + +const LegendSection = styled.div` + margin-bottom: 8px; + + &:last-child { + margin-bottom: 0; + } +` + +const SectionTitle = styled.div` + font-size: 12px; + font-weight: 600; + color: #555; + margin-bottom: 6px; +` + +const ColorScale = styled.div<{ filterStyle?: string }>` + display: flex; + height: 20px; + border-radius: 4px; + overflow: hidden; + margin-bottom: 4px; + filter: ${({ filterStyle }) => filterStyle || 'none'}; + transition: filter 0.3s ease; +` + +const ColorBlock = styled.div<{ color: string }>` + flex: 1; + background-color: ${(props) => props.color}; +` + +const ScaleLabels = styled.div` + display: flex; + justify-content: space-between; + font-size: 10px; + color: #666; +` + +const LegendItem = styled.div` + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: #555; + margin-bottom: 4px; + + &:last-child { + margin-bottom: 0; + } +` + +const WindArrow = styled.div<{ filterStyle?: string }>` + width: 20px; + height: 2px; + background-color: #666; + position: relative; + filter: ${({ filterStyle }) => filterStyle || 'none'}; + transition: filter 0.3s ease; + + &::after { + content: ''; + position: absolute; + right: -2px; + top: -3px; + width: 0; + height: 0; + border-left: 6px solid #666; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + } +` + +const NoLayersMessage = styled.div` + font-size: 12px; + color: #999; + text-align: center; + padding: 8px 0; +` + +interface LegendContentComponentProps { + activeLayers: string[] + isCollapsed: boolean + onToggle: () => void + filterStyle: string +} + +const LegendContentComponent = ({ + activeLayers, + isCollapsed, + onToggle, + filterStyle, +}: LegendContentComponentProps) => { + if (isCollapsed) { + return ( + + + 📊 + Legend + + + ) + } + + return ( + + + Map Legend + + ✕ + + + + + {activeLayers.length === 0 ? ( + No weather layers active + ) : ( + <> + {/* Temperature Legend */} + {activeLayers.includes('Temperature') && ( + + Temperature + + + + + + + + + + + + + + + -40°C + 0°C + 40°C + + + )} + + {/* Wind Speed Legend */} + {activeLayers.includes('Wind Speed') && ( + + Wind Speed + + + + + + + + + 0 m/s + 50+ m/s + + + )} + + {/* Wind with Arrows Legend */} + {activeLayers.includes('Wind with Arrows') && ( + + Wind Direction + + + Wind Direction & Speed + + + )} + + {/* Humidity Legend */} + {activeLayers.includes('Humidity') && ( + + Humidity + + + + + + + + + 0% + 100% + + + )} + + )} + + + ) +} + +const MapLegend = () => { + const map = useMap() + const { getLayerStyle } = useControlPanelContext() + const [activeLayers, setActiveLayers] = useState([]) + const [isCollapsed, setIsCollapsed] = useState(true) + const [filterStyle, setFilterStyle] = useState('') + + // Update filter style when controls change + useEffect(() => { + setFilterStyle(getLayerStyle()) + }, [getLayerStyle]) + + useEffect(() => { + const legend = new L.Control({ position: 'topright' }) + let root: ReturnType | null = null + + const handleToggle = () => { + setIsCollapsed((prev) => !prev) + } + + const handleOverlayAdd = (e: L.LayersControlEvent) => { + setActiveLayers((prev) => { + if (!prev.includes(e.name)) { + return [...prev, e.name] + } + return prev + }) + } + + const handleOverlayRemove = (e: L.LayersControlEvent) => { + setActiveLayers((prev) => prev.filter((layer) => layer !== e.name)) + } + + legend.onAdd = () => { + const div = L.DomUtil.create('div', 'map-legend') + root = createRoot(div) + root.render( + , + ) + return div + } + + legend.addTo(map) + + // Listen for layer changes + map.on('overlayadd', handleOverlayAdd) + map.on('overlayremove', handleOverlayRemove) + + return () => { + map.off('overlayadd', handleOverlayAdd) + map.off('overlayremove', handleOverlayRemove) + legend.remove() + if (root) { + root.unmount() + } + } + }, [map]) + + // Re-render when state changes + useEffect(() => { + const legendElement = document.querySelector('.map-legend') + if (legendElement) { + const root = createRoot(legendElement) + root.render( + setIsCollapsed((prev) => !prev)} + filterStyle={filterStyle} + />, + ) + return () => { + root.unmount() + } + } + }, [activeLayers, isCollapsed, filterStyle]) + + return null +} + +export default MapLegend diff --git a/Frontend/src/pages/Profile/Profile.tsx b/Frontend/src/pages/Profile/Profile.tsx index bc93eb13..86b70413 100644 --- a/Frontend/src/pages/Profile/Profile.tsx +++ b/Frontend/src/pages/Profile/Profile.tsx @@ -2,8 +2,7 @@ import type { FC } from 'react' import { useState, useEffect } from 'react' import styled from 'styled-components' import { FullScreenLayout, MainLayout } from '../../_components' -import FavouriteComparisons from './_components/FavouriteComparisons' -import FavouriteLocations from './_components/FavouriteLocations' +import FavouriteTabs from './_components/FavouriteTabs' import ProfileSettings from './_components/ProfileSettings' const ProfileContainer = styled.div` @@ -94,8 +93,7 @@ const Profile: FC = () => { - - + diff --git a/Frontend/src/pages/Profile/_components/FavouriteComparisons.tsx b/Frontend/src/pages/Profile/_components/FavouriteComparisons.tsx deleted file mode 100644 index d722a623..00000000 --- a/Frontend/src/pages/Profile/_components/FavouriteComparisons.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import type { FC } from 'react' -import styled from 'styled-components' - -const Section = styled.div` - background-color: #c2e9ff; - border-radius: 1rem; - padding: 1rem 1.5rem 2.5rem 1.5rem; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); - font-family: 'Instrument Sans', sans-serif; -` - -const Title = styled.h2` - font-size: 1.25rem; - font-weight: bold; - margin-bottom: 1.5rem; -` - -const Grid = styled.div` - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 1rem; -` - -const Item = styled.div` - background-color: #fffffff6; - padding: 0.75rem; - border-radius: 6px; - text-align: center; - font-weight: 500; -` - -const FavouriteComparisons: FC = () => ( -
- Favourite Comparisons - - Comparison 1 - Comparison 2 - Comparison 3 - Comparison 4 - Comparison 5 - -
-) - -export default FavouriteComparisons diff --git a/Frontend/src/pages/Profile/_components/FavouriteLocations.tsx b/Frontend/src/pages/Profile/_components/FavouriteTabs.tsx similarity index 88% rename from Frontend/src/pages/Profile/_components/FavouriteLocations.tsx rename to Frontend/src/pages/Profile/_components/FavouriteTabs.tsx index cfe8eecf..6867c41b 100644 --- a/Frontend/src/pages/Profile/_components/FavouriteLocations.tsx +++ b/Frontend/src/pages/Profile/_components/FavouriteTabs.tsx @@ -29,9 +29,9 @@ const Item = styled.div` font-weight: 500; ` -const FavouriteLocations: FC = () => ( +const FavouriteTabs: FC = () => (
- Favourite Locations + Favourite Tabs Location 1 Location 2 @@ -44,4 +44,4 @@ const FavouriteLocations: FC = () => (
) -export default FavouriteLocations +export default FavouriteTabs