diff --git a/README.md b/README.md index 31466b54c2..b00794870f 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,59 @@ -# Final Project +# Creative Ideas Hub / The Pensive -Replace this readme with your own information about your project. +A collaborative platform where users can share and discover creative ideas in an immersive 3D environment. Connect with ideas and find collaborators for your projects through idea connections. -Start by briefly describing the assignment in a sentence or two. Keep it short and to the point. +OBS!!! MVP mode - header and nav not fixed/animated on mobile, basic scroll behavior. The hidden mobileheader also take room in the desktop, no time to refactor that one. -## The problem +## What it does -Describe how you approached to problem, and what tools and techniques you used to solve it. How did you plan? What technologies did you use? If you had more time, what would be next? +This app lets you: + +- Share your creative ideas with text and images +- Browse ideas in a cool 3D space where each idea is a floating orb +- Connect with ideas you're interested in collaborating on +- Receive email notifications when someone connects with your idea +- Get social media links from potential collaborators +- Navigate the 3D world using keyboard, mouse, or touch controls +- Edit and manage your own ideas (under construction) +- View and edit your profile (under construction) + +## How we built it + +### Frontend (React) + +- **React 19** - The main framework for building the user interface +- **Three.js** - For the 3D graphics and interactive scene +- **Zustand** - For managing app state (like user data and ideas) +- **Styled Components** - For styling the app +- **React Router** - For navigation between pages + +### Backend (Node.js) + +- **Express.js** - The server framework +- **MongoDB** - Database to store users, ideas, and connections +- **JWT** - For user authentication and security +- **Cloudinary** - For storing and managing images +- **Multer** - For handling file uploads ## View it live -Every project should be deployed somewhere. Be sure to include the link to the deployed project so that the viewer can click around and see what it's all about. \ No newline at end of file +- **Frontend**: https://aesthetic-dolphin-63dc60.netlify.app/ +- **Backend API**: https://project-final-backend-gq0x.onrender.com + +## API Documentation + +The backend uses the `express-list-endpoints` library to automatically generate API documentation. You can view all available endpoints at: +https://project-final-backend-gq0x.onrender.com/api-docs + +## Future improvements + +If we had more time, we'd add: + +- BETTER errorhandling +- BETTER Loading States through out the app +- BETTER User Experience +- Well, to be honest, finish the app? Its only at MVP stage +- Real-time chat between users +- More 3D effects and animations +- Idea categories and search +- Video upload support diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js new file mode 100644 index 0000000000..834ae60a52 --- /dev/null +++ b/backend/middleware/auth.js @@ -0,0 +1,50 @@ +import jwt from 'jsonwebtoken'; +import User from '../models/User.js'; + +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; + +export const authenticateToken = async (req, res, next) => { + try { + // Get token from header + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN + + console.log('Auth: Checking token...', { + hasAuthHeader: !!authHeader, + hasToken: !!token, + tokenLength: token ? token.length : 0 + }); + + if (!token) { + return res.status(401).json({ message: 'Access token required' }); + } + + // Verify token + const decoded = jwt.verify(token, JWT_SECRET); + console.log('Auth: Token decoded successfully', { userId: decoded.userId }); + + // Find user + const user = await User.findById(decoded.userId).select('-password'); + + if (!user) { + console.log('Auth: User not found', { userId: decoded.userId }); + return res.status(401).json({ message: 'Invalid token' }); + } + + console.log('Auth: User authenticated successfully', { + userId: user._id, + email: user.email + }); + + // Add user to request object + req.user = user; + next(); + } catch (error) { + console.error('Auth: Token verification failed', error.message); + return res.status(403).json({ message: 'Invalid token' }); + } +}; + +// Default export for convenience +const auth = authenticateToken; +export default auth; diff --git a/backend/middleware/upload.js b/backend/middleware/upload.js new file mode 100644 index 0000000000..7a69481890 --- /dev/null +++ b/backend/middleware/upload.js @@ -0,0 +1,26 @@ +import multer from 'multer'; + +// Keep files in memory as Buffer for Cloudinary upload +const storage = multer.memoryStorage(); + +// File filter to only allow certain file types +const fileFilter = (req, file, cb) => { + // Allow images only + if (file.mimetype.startsWith('image/')) { + cb(null, true); + } else { + cb(new Error('Only image files are allowed!'), false); + } +}; + +// Configure multer for Cloudinary uploads +const upload = multer({ + storage: storage, // Keep in memory for Cloudinary + fileFilter: fileFilter, + limits: { + fileSize: 5 * 1024 * 1024, // 5MB limit + files: 5, // Maximum 5 files per request + }, +}); + +export default upload; diff --git a/backend/models/Idea.js b/backend/models/Idea.js new file mode 100644 index 0000000000..ba7f969eba --- /dev/null +++ b/backend/models/Idea.js @@ -0,0 +1,62 @@ +import mongoose from 'mongoose'; + +const ideaSchema = new mongoose.Schema( + { + title: { + type: String, + required: [true, 'Title is required'], + trim: true, + minlength: [3, 'Title must be at least 3 characters long'], + maxlength: [100, 'Title cannot exceed 100 characters'], + }, + description: { + type: String, + required: [true, 'Description is required'], + trim: true, + minlength: [10, 'Description must be at least 10 characters long'], + maxlength: [2000, 'Description cannot exceed 2000 characters'], + }, + creator: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: [true, 'Creator is required'], + }, + likeCount: { + type: Number, + default: 0, + min: 0, + }, + connectionCount: { + type: Number, + default: 0, + min: 0, + }, + images: [ + { + type: String, + trim: true, + }, + ], + }, + { + timestamps: true, + } +); + +// Virtual field to get image count +ideaSchema.virtual('imageCount').get(function () { + return this.images.length; +}); + +// Virtual field to check if idea has images +ideaSchema.virtual('hasImages').get(function () { + return this.images.length > 0; +}); + +// Ensure virtual fields are serialized +ideaSchema.set('toJSON', { virtuals: true }); +ideaSchema.set('toObject', { virtuals: true }); + +const Idea = mongoose.model('Idea', ideaSchema); + +export default Idea; diff --git a/backend/models/User.js b/backend/models/User.js new file mode 100644 index 0000000000..1295030d67 --- /dev/null +++ b/backend/models/User.js @@ -0,0 +1,168 @@ +import mongoose from 'mongoose'; +import bcrypt from 'bcrypt'; + +const userSchema = new mongoose.Schema( + { + email: { + type: String, + required: [true, 'Email is required'], + unique: true, + lowercase: true, + trim: true, + match: [ + /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/, + 'Please enter a valid email', + ], + }, + password: { + type: String, + required: [true, 'Password is required'], + minlength: [6, 'Password must be at least 6 characters long'], + }, + firstName: { + type: String, + required: [true, 'First name is required'], + trim: true, + minlength: [2, 'First name must be at least 2 characters long'], + maxlength: [50, 'First name cannot exceed 50 characters'], + }, + lastName: { + type: String, + required: [true, 'Last name is required'], + trim: true, + minlength: [2, 'Last name must be at least 2 characters long'], + maxlength: [50, 'Last name cannot exceed 50 characters'], + }, + role: { + type: String, + default: 'creative mind', + trim: true, + maxlength: [100, 'Role cannot exceed 100 characters'], + }, + description: { + type: String, + trim: true, + maxlength: [500, 'Description cannot exceed 500 characters'], + }, + link: { + type: String, + trim: true, + maxlength: [200, 'Link cannot exceed 200 characters'], + }, + likedIdeas: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: 'Idea', + }, + ], + connectedIdeas: [ + { + idea: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Idea', + }, + message: { + type: String, + required: true, + maxlength: [500, 'Connection message cannot exceed 500 characters'], + }, + socialLink: { + type: String, + trim: true, + maxlength: [200, 'Social link cannot exceed 200 characters'], + }, + connectedAt: { + type: Date, + default: Date.now, + }, + }, + ], + receivedConnections: [ + { + idea: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Idea', + }, + connectedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + }, + message: { + type: String, + required: true, + maxlength: [500, 'Connection message cannot exceed 500 characters'], + }, + socialLink: { + type: String, + trim: true, + maxlength: [200, 'Social link cannot exceed 200 characters'], + }, + connectedAt: { + type: Date, + default: Date.now, + }, + }, + ], + }, + { + timestamps: true, + } +); + +// Virtual field to get full name +userSchema.virtual('fullName').get(function () { + return `${this.firstName} ${this.lastName}`; +}); + +// Virtual field to get like count +userSchema.virtual('likeCount').get(function () { + return this.likedIdeas ? this.likedIdeas.length : 0; +}); + +// Virtual field to get connection count (ideas I connected to) +userSchema.virtual('connectionCount').get(function () { + return this.connectedIdeas ? this.connectedIdeas.length : 0; +}); + +// Virtual field to get received connections count (people who connected to my ideas) +userSchema.virtual('receivedConnectionCount').get(function () { + return this.receivedConnections ? this.receivedConnections.length : 0; +}); + +// Virtual field to check if user has description +userSchema.virtual('hasDescription').get(function () { + return this.description && this.description.trim().length > 0; +}); + +// Virtual field to check if user has link +userSchema.virtual('hasLink').get(function () { + return this.link && this.link.trim().length > 0; +}); + +// Ensure virtual fields are serialized +userSchema.set('toJSON', { virtuals: true }); +userSchema.set('toObject', { virtuals: true }); + +// Hash password before saving +userSchema.pre('save', async function (next) { + // Only hash the password if it has been modified (or is new) + if (!this.isModified('password')) return next(); + + try { + // Hash password with salt rounds of 10 + const hashedPassword = await bcrypt.hash(this.password, 10); + this.password = hashedPassword; + next(); + } catch (error) { + next(error); + } +}); + +// Method to compare password for login +userSchema.methods.comparePassword = async function (candidatePassword) { + return bcrypt.compare(candidatePassword, this.password); +}; + +const User = mongoose.model('User', userSchema); + +export default User; diff --git a/backend/package.json b/backend/package.json index 08f29f2448..625bdea30f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,9 +12,18 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "bcrypt": "^5.1.0", + "cloudinary": "^2.7.0", "cors": "^2.8.5", + "dotenv": "^16.0.3", "express": "^4.17.3", + "express-list-endpoints": "^6.0.0", + "express-validator": "^7.0.1", + "jsonwebtoken": "^9.0.0", + "mongodb": "^6.18.0", "mongoose": "^8.4.0", + "multer": "^2.0.2", + "nodemailer": "^7.0.5", "nodemon": "^3.0.1" } -} \ No newline at end of file +} diff --git a/backend/routes/auth.js b/backend/routes/auth.js new file mode 100644 index 0000000000..87b8853d70 --- /dev/null +++ b/backend/routes/auth.js @@ -0,0 +1,148 @@ +import express from 'express'; +import { body, validationResult } from 'express-validator'; +import jwt from 'jsonwebtoken'; +import User from '../models/User.js'; +import auth from '../middleware/auth.js'; + +const router = express.Router(); +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; + +// Helper function to generate JWT token +const generateToken = (userId) => { + return jwt.sign({ userId }, JWT_SECRET, { expiresIn: '24h' }); +}; + +// Validate token route - requires auth middleware +router.get('/validate', auth, async (req, res) => { + try { + // The auth middleware already verified the token and added user to req + const user = req.user; + + res.json({ + message: 'Token is valid', + user: { + _id: user._id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + fullName: user.fullName, + role: user.role, + }, + }); + } catch (error) { + res.status(500).json({ message: 'Server error', error: error.message }); + } +}); + +// Registration route with validation with express-validator +router.post( + '/register', + [ + body('email').isEmail().normalizeEmail(), + body('password').isLength({ min: 6 }), + body('firstName').trim().isLength({ min: 2, max: 50 }), + body('lastName').trim().isLength({ min: 2, max: 50 }), + ], + async (req, res) => { + try { + // Check for validation errors + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + message: 'Validation failed', + errors: errors.array(), + }); + } + + const { email, password, firstName, lastName } = req.body; + + // Check if user already exists + const existingUser = await User.findOne({ email }); + if (existingUser) { + return res.status(400).json({ message: 'Email already registered' }); + } + + // Create new user + const user = new User({ + email, + password, + firstName, + lastName, + }); + + await user.save(); + + // Generate token + const token = generateToken(user._id); + + // Return user data (without password) and token + res.status(201).json({ + message: 'User created successfully', + user: { + _id: user._id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + fullName: user.fullName, + role: user.role, + }, + token, + }); + } catch (error) { + res.status(500).json({ message: 'Server error', error: error.message }); + } + } +); + +// Login routen with validation with express-validator +router.post( + '/login', + [body('email').isEmail().normalizeEmail(), body('password').notEmpty()], + async (req, res) => { + try { + // Check for validation errors + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + message: 'Validation failed', + errors: errors.array(), + }); + } + + const { email, password } = req.body; + + // Find user + const user = await User.findOne({ email }); + if (!user) { + return res.status(401).json({ message: 'Invalid credentials' }); + } + + // Check password + const isPasswordValid = await user.comparePassword(password); + if (!isPasswordValid) { + return res.status(401).json({ message: 'Invalid credentials' }); + } + + // Generate token + const token = generateToken(user._id); + + // Return user data and token + res.json({ + message: 'Login successful', + user: { + _id: user._id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + fullName: user.fullName, + role: user.role, + }, + token, + }); + } catch (error) { + res.status(500).json({ message: 'Server error', error: error.message }); + } + } +); + +export default router; diff --git a/backend/routes/docs.js b/backend/routes/docs.js new file mode 100644 index 0000000000..523b34d39f --- /dev/null +++ b/backend/routes/docs.js @@ -0,0 +1,50 @@ +import express from 'express'; +import listEndpoints from 'express-list-endpoints'; + +const router = express.Router(); + +// API Documentation endpoint +router.get('/', (req, res) => { + const endpoints = listEndpoints(req.app); + + // Add descriptions to each endpoint + const documentedEndpoints = endpoints.map((endpoint) => { + const descriptions = { + '/': 'Welcome page', + '/api-docs': 'API Documentation', + '/auth/register': 'Register new user', + '/auth/login': 'Login user', + '/ideas': 'Get all ideas or create new idea', + '/ideas/:id': 'Get, update, or delete specific idea', + '/ideas/:id/like': 'Like/unlike an idea', + '/users/profile': 'Get or update user profile', + '/users/liked-ideas/:ideaId': 'Unlike an idea', + '/users/account': 'Delete user account', + }; + + return { + ...endpoint, + description: descriptions[endpoint.path] || 'No description available', + authentication: endpoint.path.startsWith('/auth') + ? 'None' + : endpoint.path === '/' || endpoint.path === '/api-docs' + ? 'None' + : 'Required', + }; + }); + + res.json({ + message: 'Creative Ideas API Documentation', + version: '1.0.0', + description: + 'A RESTful API for managing creative ideas and user connections', + totalEndpoints: documentedEndpoints.length, + authentication: { + type: 'JWT Bearer Token', + header: 'Authorization: Bearer YOUR_TOKEN', + }, + endpoints: documentedEndpoints, + }); +}); + +export default router; diff --git a/backend/routes/ideas.js b/backend/routes/ideas.js new file mode 100644 index 0000000000..e7a17b53f5 --- /dev/null +++ b/backend/routes/ideas.js @@ -0,0 +1,584 @@ +import express from 'express'; +import { body, validationResult } from 'express-validator'; +import mongoose from 'mongoose'; +import Idea from '../models/Idea.js'; +import User from '../models/User.js'; +import { authenticateToken } from '../middleware/auth.js'; +import upload from '../middleware/upload.js'; +import cloudinaryService from '../services/cloudinaryService.js'; + +import { + sendConnectionNotification, + sendConnectionConfirmation, +} from '../services/emailService.js'; + +const router = express.Router(); + +// Utility function to clean up orphaned connections +const cleanupOrphanedConnections = async () => { + try { + // Get all idea IDs that exist + const existingIdeaIds = await Idea.find({}, '_id'); + + // Clean up connectedIdeas that reference non-existent ideas + await User.updateMany( + {}, + { + $pull: { + connectedIdeas: { + idea: { $nin: existingIdeaIds }, + }, + receivedConnections: { + idea: { $nin: existingIdeaIds }, + }, + likedIdeas: { + $nin: existingIdeaIds, + }, + }, + } + ); + } catch (error) { + console.error('Error cleaning up orphaned connections:', error); + } +}; + +// Protect write/modify routes only; GET routes remain public + +router.post( + '/', + authenticateToken, + upload.array('files', 5), // Handle up to 5 files with field name 'files' + [ + body('title') + .trim() + .isLength({ min: 3, max: 100 }) + .withMessage('Title must be 3-100 characters'), + body('description') + .trim() + .isLength({ min: 10, max: 2000 }) + .withMessage('Description must be 10-2000 characters'), + ], + async (req, res) => { + try { + // Check if database is connected + if (mongoose.connection.readyState !== 1) { + console.error( + 'Database not connected. ReadyState:', + mongoose.connection.readyState + ); + return res.status(503).json({ + message: 'Database connection not available', + error: 'Database disconnected', + }); + } + + // Check validations error + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res + .status(400) + .json({ message: 'Validation failed', errors: errors.array() }); + } + + const { title, description } = req.body; + + // Validate required fields + if (!title || !description) { + return res.status(400).json({ + message: 'Title and description are required', + }); + } + + // Process uploaded files with Cloudinary + const imageUrls = []; + + if (req.files && req.files.length > 0) { + // Upload images to Cloudinary + const uploadResult = await cloudinaryService.uploadMultipleImages( + req.files + ); + + if (!uploadResult.success) { + return res.status(500).json({ + message: 'Failed to upload images', + error: uploadResult.error, + }); + } + + imageUrls.push(...uploadResult.urls); + } + + // Create a new idea object + const idea = new Idea({ + title, + description, + creator: req.user._id, + likeCount: 0, + connectionCount: 0, + images: imageUrls, // Add the image URLs to the idea + }); + + // Save to database + await idea.save(); + + // Populate the creator field before sending response + await idea.populate('creator', 'firstName lastName email fullName role'); + + res.json({ + message: 'Idea object created and saved to database!', + idea: idea, + }); + } catch (error) { + console.error('Error creating idea:', error); + res.status(500).json({ + message: 'Internal server error', + error: error.message, + stack: process.env.NODE_ENV === 'development' ? error.stack : undefined, + }); + } + } +); + +// Get all ideas +router.get('/', async (req, res) => { + try { + // Clean up orphaned connections periodically (only in development or when explicitly requested) + if ( + process.env.NODE_ENV === 'development' || + req.query.cleanup === 'true' + ) { + await cleanupOrphanedConnections(); + } + + // Use stored counters instead of aggregation for better performance + const ideas = await Idea.find() + .populate('creator', 'firstName lastName email fullName role') + .sort({ createdAt: -1 }) + .lean(); + + res.json({ + message: 'Ideas retrieved successfully', + ideas: ideas, + }); + } catch (error) { + console.error('Error in GET /ideas:', error); + res.status(500).json({ + message: 'Server error', + error: error.message, + stack: process.env.NODE_ENV === 'development' ? error.stack : undefined, + }); + } +}); + +// Get single idea by ID +router.get('/:id', async (req, res) => { + try { + const idea = await Idea.findById(req.params.id) + .populate('creator', 'firstName lastName email fullName role') + .populate('likedBy', 'firstName lastName email fullName') + .populate('connectedBy.user', 'firstName lastName email fullName'); + + if (!idea) { + return res.status(404).json({ message: 'Idea not found' }); + } + + res.json({ + message: 'Idea retrieved successfully', + idea: idea, + }); + } catch (error) { + res.status(500).json({ message: 'Server error', error: error.message }); + } +}); + +// Update idea +router.put('/:id', authenticateToken, async (req, res) => { + try { + const idea = await Idea.findById(req.params.id); + + if (!idea) { + return res.status(404).json({ message: 'Idea not found' }); + } + + // Check if user is the creator + if (idea.creator.toString() !== req.user._id.toString()) { + return res + .status(403) + .json({ message: 'Not authorized to update this idea' }); + } + + const updatedIdea = await Idea.findByIdAndUpdate(req.params.id, req.body, { + new: true, + runValidators: true, + }).populate('creator', 'firstName lastName email fullName role'); + + res.json({ + message: 'Idea updated successfully', + idea: updatedIdea, + }); + } catch (error) { + res.status(500).json({ message: 'Server error', error: error.message }); + } +}); + +// Delete idea +router.delete('/:id', authenticateToken, async (req, res) => { + try { + const idea = await Idea.findById(req.params.id); + + if (!idea) { + return res.status(404).json({ message: 'Idea not found' }); + } + + // Check if user is the creator + if (idea.creator.toString() !== req.user._id.toString()) { + return res + .status(403) + .json({ message: 'Not authorized to delete this idea' }); + } + + // Clean up all connections to this idea before deleting + await User.updateMany( + {}, + { + $pull: { + connectedIdeas: { idea: req.params.id }, + receivedConnections: { idea: req.params.id }, + likedIdeas: req.params.id, + }, + } + ); + + await Idea.findByIdAndDelete(req.params.id); + + res.json({ message: 'Idea deleted successfully' }); + } catch (error) { + res.status(500).json({ message: 'Server error', error: error.message }); + } +}); + +// Like/unlike idea +router.post('/:id/like', authenticateToken, async (req, res) => { + try { + const idea = await Idea.findById(req.params.id); + + if (!idea) { + return res.status(404).json({ message: 'Idea not found' }); + } + + const userId = req.user._id; + + // Check if user is trying to like their own idea + if (idea.creator.toString() === userId.toString()) { + return res.status(400).json({ + message: 'You cannot like your own idea', + }); + } + + // Check if user already liked this idea + const user = await User.findById(userId); + const isLiked = user.likedIdeas.includes(req.params.id); + + if (isLiked) { + // Unlike - remove from user's likedIdeas and decrement counter + user.likedIdeas = user.likedIdeas.filter( + (ideaId) => ideaId.toString() !== req.params.id + ); + idea.likeCount = Math.max(0, idea.likeCount - 1); + } else { + // Like - add to user's likedIdeas and increment counter + user.likedIdeas.push(req.params.id); + idea.likeCount += 1; + } + + // Save both user and idea + const savedUser = await user.save(); + const savedIdea = await idea.save(); + + res.json({ + message: isLiked ? 'Idea unliked' : 'Idea liked', + success: true, + user: savedUser, + idea: savedIdea, + }); + } catch (error) { + res.status(500).json({ message: 'Server error', error: error.message }); + } +}); + +// Connect to idea +router.post('/:id/connect', authenticateToken, async (req, res) => { + try { + // 1. Get the idea ID from the URL parameter + const ideaId = req.params.id; + + // 2. Get the connection message and social link from request body + const { message, socialLink } = req.body; + + // 3. Get the user ID from the authentication token (already available from middleware) + const userId = req.user._id; + + // 4. Validate the message + if (!message || message.trim().length === 0) { + return res.status(400).json({ + message: 'Connection message is required', + }); + } + + if (message.length > 500) { + return res.status(400).json({ + message: 'Connection message cannot exceed 500 characters', + }); + } + + // 5. Find the idea and check if it exists + const idea = await Idea.findById(ideaId); + if (!idea) { + return res.status(404).json({ + message: 'Idea not found', + }); + } + + // 6. Check if user is trying to connect to their own idea + if (idea.creator.toString() === userId.toString()) { + return res.status(400).json({ + message: 'You cannot connect to your own idea', + }); + } + + // 7. Check if user already connected to this idea + const user = await User.findById(userId); + const alreadyConnected = user.connectedIdeas.some( + (connection) => connection.idea.toString() === ideaId.toString() + ); + + if (alreadyConnected) { + return res.status(400).json({ + message: 'You have already connected to this idea', + }); + } + + // 8. Add the connection to the user's connectedIdeas array + user.connectedIdeas.push({ + idea: ideaId, + message: message.trim(), + socialLink: socialLink || null, + connectedAt: new Date(), + }); + + // 9. Add the connection to the idea creator's receivedConnections array + const ideaCreator = await User.findById(idea.creator); + if (ideaCreator) { + ideaCreator.receivedConnections.push({ + idea: ideaId, + connectedBy: userId, + message: message.trim(), + socialLink: socialLink || null, + connectedAt: new Date(), + }); + } + + // 10. Increment the idea's connection counter + idea.connectionCount += 1; + + // 11. Save all updates + await Promise.all([user.save(), ideaCreator.save(), idea.save()]); + + // 12. Send email notifications asynchronously (don't block the response) + // Fire and forget - don't await emails to prevent blocking the response + Promise.all([ + sendConnectionNotification( + ideaCreator, + user, + idea, + message.trim(), + socialLink + ), + sendConnectionConfirmation(user, ideaCreator, idea), + ]).catch((emailError) => + console.error('Failed to send connection emails:', emailError) + ); + + // 13. Populate the updated user data before returning + const populatedUser = await User.findById(userId) + .populate({ + path: 'likedIdeas', + match: { _id: { $ne: null } }, // Filter out null references + }) + .populate({ + path: 'connectedIdeas.idea', + match: { _id: { $ne: null } }, // Filter out null references + populate: { + path: 'creator', + select: 'firstName lastName email fullName', + }, + }) + .populate({ + path: 'receivedConnections.idea', + match: { _id: { $ne: null } }, // Filter out null references + populate: { + path: 'creator', + select: 'firstName lastName email fullName', + }, + }) + .populate({ + path: 'receivedConnections.connectedBy', + select: 'firstName lastName email fullName', + }) + .lean(); // Convert to plain JavaScript object to handle null values better + + // Filter out any null values from arrays that might have been populated + if (populatedUser) { + populatedUser.likedIdeas = (populatedUser.likedIdeas || []).filter( + (idea) => idea !== null + ); + populatedUser.connectedIdeas = ( + populatedUser.connectedIdeas || [] + ).filter((conn) => conn && conn.idea !== null); + populatedUser.receivedConnections = ( + populatedUser.receivedConnections || [] + ).filter( + (conn) => conn && conn.idea !== null && conn.connectedBy !== null + ); + } + + // 14. Return success with updated user data + res.json({ + message: 'Successfully connected to idea', + success: true, + user: populatedUser, + idea: idea, + }); + } catch (error) { + console.error('Error in POST /ideas/:id/connect:', error); + res.status(500).json({ + message: 'Server error', + error: error.message, + }); + } +}); + +// Disconnect from idea +router.delete('/:id/connect', authenticateToken, async (req, res) => { + try { + // 1. Get the idea ID from the URL parameter + const ideaId = req.params.id; + + // 2. Get the user ID from the authentication token + const userId = req.user._id; + + // 3. Find the idea and check if it exists + const idea = await Idea.findById(ideaId); + if (!idea) { + return res.status(404).json({ + message: 'Idea not found', + }); + } + + // 4. Check if user has connected to this idea + const user = await User.findById(userId); + const connectionIndex = user.connectedIdeas.findIndex( + (connection) => connection.idea.toString() === ideaId.toString() + ); + + if (connectionIndex === -1) { + return res.status(400).json({ + message: 'You have not connected to this idea', + }); + } + + // 5. Remove the connection from the user's connectedIdeas array + user.connectedIdeas.splice(connectionIndex, 1); + + // 6. Remove the connection from the idea creator's receivedConnections array + const ideaCreator = await User.findById(idea.creator); + if (ideaCreator) { + ideaCreator.receivedConnections = ideaCreator.receivedConnections.filter( + (connection) => + !( + connection.idea.toString() === ideaId.toString() && + connection.connectedBy.toString() === userId.toString() + ) + ); + } + + // 7. Decrement the idea's connection counter + idea.connectionCount = Math.max(0, idea.connectionCount - 1); + + // 8. Save all updates + await Promise.all([user.save(), ideaCreator.save(), idea.save()]); + + // 9. Populate the updated user data before returning + const populatedUser = await User.findById(userId) + .populate({ + path: 'likedIdeas', + match: { _id: { $ne: null } }, // Filter out null references + }) + .populate({ + path: 'connectedIdeas.idea', + match: { _id: { $ne: null } }, // Filter out null references + populate: { + path: 'creator', + select: 'firstName lastName email fullName', + }, + }) + .populate({ + path: 'receivedConnections.idea', + match: { _id: { $ne: null } }, // Filter out null references + populate: { + path: 'creator', + select: 'firstName lastName email fullName', + }, + }) + .populate({ + path: 'receivedConnections.connectedBy', + select: 'firstName lastName email fullName', + }) + .lean(); // Convert to plain JavaScript object to handle null values better + + // Filter out any null values from arrays that might have been populated + if (populatedUser) { + populatedUser.likedIdeas = (populatedUser.likedIdeas || []).filter( + (idea) => idea !== null + ); + populatedUser.connectedIdeas = ( + populatedUser.connectedIdeas || [] + ).filter((conn) => conn && conn.idea !== null); + populatedUser.receivedConnections = ( + populatedUser.receivedConnections || [] + ).filter( + (conn) => conn && conn.idea !== null && conn.connectedBy !== null + ); + } + + // 10. Return success with updated user data + res.json({ + message: 'Successfully disconnected from idea', + success: true, + user: populatedUser, + idea: idea, + }); + } catch (error) { + console.error('Error in DELETE /ideas/:id/connect:', error); + res.status(500).json({ + message: 'Server error', + error: error.message, + }); + } +}); + +// Manual cleanup endpoint for orphaned connections (development only) +router.post('/cleanup-orphaned', async (req, res) => { + if (process.env.NODE_ENV !== 'development') { + return res.status(404).json({ message: 'Endpoint not found' }); + } + + try { + await cleanupOrphanedConnections(); + res.json({ message: 'Orphaned connections cleaned up successfully' }); + } catch (error) { + res.status(500).json({ message: 'Server error', error: error.message }); + } +}); + +export default router; diff --git a/backend/routes/users.js b/backend/routes/users.js new file mode 100644 index 0000000000..509b287813 --- /dev/null +++ b/backend/routes/users.js @@ -0,0 +1,146 @@ +import express from 'express'; +import { body, validationResult } from 'express-validator'; +import User from '../models/User.js'; +import Idea from '../models/Idea.js'; +import { authenticateToken } from '../middleware/auth.js'; + +const router = express.Router(); + +// All routes require authentication +router.use(authenticateToken); + +// Get user profile +router.get('/profile', authenticateToken, async (req, res) => { + try { + const populatedUser = await User.findById(req.user._id) + .populate('likedIdeas') + .populate({ + path: 'connectedIdeas.idea', + populate: { + path: 'creator', + select: 'firstName lastName email fullName', + }, + }) + .populate({ + path: 'receivedConnections.idea', + populate: { + path: 'creator', + select: 'firstName lastName email fullName', + }, + }) + .populate({ + path: 'receivedConnections.connectedBy', + select: 'firstName lastName email fullName', + }); + + if (!populatedUser) { + return res.status(404).json({ message: 'User not found' }); + } + + res.json({ + message: 'User profile retrieved successfully', + user: populatedUser, + }); + } catch (error) { + console.error('Error in GET /users/profile:', error); + res.status(500).json({ message: 'Server error', error: error.message }); + } +}); + +// Update user profile +router.put( + '/profile', + [ + body('firstName').optional().trim().isLength({ min: 2, max: 50 }), + body('lastName').optional().trim().isLength({ min: 2, max: 50 }), + body('role').optional().trim().isLength({ max: 100 }), + body('description').optional().trim().isLength({ max: 500 }), + body('link').optional().trim().isLength({ max: 200 }), + ], + async (req, res) => { + try { + // Check for validation errors + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + message: 'Validation failed', + errors: errors.array(), + }); + } + + const updatedUser = await User.findByIdAndUpdate(req.user._id, req.body, { + new: true, + runValidators: true, + }).select('-password'); + + res.json({ + message: 'Profile updated successfully', + user: updatedUser, + }); + } catch (error) { + res.status(500).json({ message: 'Server error', error: error.message }); + } + } +); + +// Get user's connections +router.get('/connections', async (req, res) => { + try { + const user = await User.findById(req.user._id) + .populate('connectedIdeas.idea', 'title description creator') + .select('-password'); + + res.json({ + message: 'Connections retrieved successfully', + connections: user.connectedIdeas || [], + }); + } catch (error) { + res.status(500).json({ message: 'Server error', error: error.message }); + } +}); + +// Unlike a liked idea +router.delete('/liked-ideas/:ideaId', async (req, res) => { + try { + const { ideaId } = req.params; + + // Remove idea from user's likedIdeas + const user = await User.findByIdAndUpdate( + req.user._id, + { $pull: { likedIdeas: ideaId } }, + { new: true } + ).select('-password'); + + // Remove user from idea's likedBy + await Idea.findByIdAndUpdate(ideaId, { $pull: { likedBy: req.user._id } }); + + res.json({ + message: 'Idea unliked successfully', + user: user, + }); + } catch (error) { + res.status(500).json({ message: 'Server error', error: error.message }); + } +}); + +// Delete user account +router.delete('/account', async (req, res) => { + try { + const userId = req.user._id; + + // Delete all ideas created by this user + await Idea.deleteMany({ creator: userId }); + + // Remove user from all liked ideas + await Idea.updateMany({ likedBy: userId }, { $pull: { likedBy: userId } }); + + // Delete the user + await User.findByIdAndDelete(userId); + + res.json({ message: 'Account deleted successfully' }); + } catch (error) { + res.status(500).json({ message: 'Server error', error: error.message }); + } +}); + +export default router; diff --git a/backend/server.js b/backend/server.js index 070c875189..8c7e612da1 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,22 +1,116 @@ -import express from "express"; -import cors from "cors"; -import mongoose from "mongoose"; +import express from 'express'; +import cors from 'cors'; +import mongoose from 'mongoose'; +import dotenv from 'dotenv'; +import authRoutes from './routes/auth.js'; +import ideaRoutes from './routes/ideas.js'; +import userRoutes from './routes/users.js'; +import docsRoutes from './routes/docs.js'; -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; -mongoose.connect(mongoUrl); -mongoose.Promise = Promise; +// Load environment variables +dotenv.config(); +// --- MongoDB connection diagnostics --- +const redactMongoUri = (uri = '') => { + try { + if (!uri) return '[empty]'; + // Hide credentials but show host/db for debugging + const url = new URL( + uri.replace('mongodb+srv://', 'https://').replace('mongodb://', 'http://') + ); + const protocol = uri.startsWith('mongodb+srv://') + ? 'mongodb+srv://' + : 'mongodb://'; + const host = url.host; + const pathname = url.pathname; + return `${protocol}@${host}${pathname}`; + } catch { + return '[unparseable URI]'; + } +}; + +const mongoUrl = process.env.MONGO_URL || 'mongodb://localhost/final-project'; const port = process.env.PORT || 8080; + +console.log('Boot: Node version:', process.version); +console.log('Boot: Mongo URI (redacted):', redactMongoUri(mongoUrl)); +console.log('Boot: PORT:', port); + const app = express(); -app.use(cors()); -app.use(express.json()); +// MongoDB connection +console.log('Mongo: attempting to connect...'); +mongoose.set('strictQuery', true); +mongoose.connect(mongoUrl, { + serverSelectionTimeoutMS: 15000, +}); -app.get("/", (req, res) => { - res.send("Hello Technigo!"); +const database = mongoose.connection; + +database.on('connecting', () => console.log('Mongo: connecting...')); +database.on('connected', () => console.log('Mongo: connected')); +database.on('open', () => console.log('Mongo: connection open')); +database.on('error', (err) => + console.error('Mongo: connection error:', err?.message || err) +); +database.on('disconnected', () => console.warn('Mongo: disconnected')); +database.on('reconnected', () => console.log('Mongo: reconnected')); + +// Basic error handling already above; keep process-level guards +process.on('unhandledRejection', (reason) => { + console.error('Process: unhandledRejection:', reason); }); +process.on('uncaughtException', (err) => { + console.error('Process: uncaughtException:', err); +}); + +// Start server only after database connects +database.once('open', () => { + console.log('✅ Connected to MongoDB'); -// Start the server -app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`); + // Start server here + app.listen(port, () => { + console.log(`🚀 Server running on http://localhost:${port}`); + }); }); + +// Middleware +// CORS configuration +const allowedOrigins = [ + 'http://localhost:5173', // Vite dev server + 'http://localhost:3000', // Common React dev server + 'http://localhost:4173', // Vite preview + 'https://aesthetic-dolphin-63dc60.netlify.app', // Your Netlify URL + process.env.FRONTEND_URL, // Production frontend URL +].filter(Boolean); // Remove any undefined values + +app.use( + cors({ + origin: function (origin, callback) { + // Allow requests with no origin (like mobile apps or curl requests) + if (!origin) return callback(null, true); + + if (allowedOrigins.indexOf(origin) !== -1) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'], + }) +); +app.use(express.json()); + +// Static file serving removed - now using Cloudinary for image storage + +// Basic route +app.get('/', (req, res) => { + res.send('Hello Technigo!'); +}); + +app.use('/auth', authRoutes); +app.use('/ideas', ideaRoutes); +app.use('/users', userRoutes); +app.use('/api-docs', docsRoutes); diff --git a/backend/services/cloudinaryService.js b/backend/services/cloudinaryService.js new file mode 100644 index 0000000000..b5a47454b7 --- /dev/null +++ b/backend/services/cloudinaryService.js @@ -0,0 +1,98 @@ +import { v2 as cloudinary } from 'cloudinary'; +import dotenv from 'dotenv'; + +// Load environment variables explicitly +dotenv.config(); + +// Configure Cloudinary +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, +}); + +// Upload image to Cloudinary +const uploadImage = async (file) => { + try { + // Convert buffer to base64 string + const b64 = Buffer.from(file.buffer).toString('base64'); + const dataURI = `data:${file.mimetype};base64,${b64}`; + + // Upload to Cloudinary + const result = await cloudinary.uploader.upload(dataURI, { + folder: 'ideas', // Organize images in a folder + resource_type: 'auto', + transformation: [ + { width: 800, height: 600, crop: 'limit' }, // Resize for better performance + { quality: 'auto' }, // Optimize quality + ], + }); + + return { + success: true, + url: result.secure_url, + publicId: result.public_id, + }; + } catch (error) { + console.error('Cloudinary upload error:', error); + return { + success: false, + error: error.message, + }; + } +}; + +// Upload multiple images +const uploadMultipleImages = async (files) => { + try { + const uploadPromises = files.map((file) => uploadImage(file)); + const results = await Promise.all(uploadPromises); + + // Check if all uploads were successful + const failedUploads = results.filter((result) => !result.success); + if (failedUploads.length > 0) { + return { + success: false, + error: 'Some images failed to upload', + failedUploads, + }; + } + + // Extract URLs from successful uploads + const imageUrls = results.map((result) => result.url); + + return { + success: true, + urls: imageUrls, + }; + } catch (error) { + console.error('Multiple image upload error:', error); + return { + success: false, + error: error.message, + }; + } +}; + +// Delete image from Cloudinary (for cleanup) +const deleteImage = async (publicId) => { + try { + const result = await cloudinary.uploader.destroy(publicId); + return { + success: true, + result, + }; + } catch (error) { + console.error('Cloudinary delete error:', error); + return { + success: false, + error: error.message, + }; + } +}; + +export default { + uploadImage, + uploadMultipleImages, + deleteImage, +}; diff --git a/backend/services/emailService.js b/backend/services/emailService.js new file mode 100644 index 0000000000..dfa1965d5c --- /dev/null +++ b/backend/services/emailService.js @@ -0,0 +1,206 @@ +import nodemailer from 'nodemailer'; +import dotenv from 'dotenv'; + +dotenv.config(); + +// ===== EMAIL CONFIGURATION ===== +// Create a transporter object for sending emails via Gmail +const transporter = nodemailer.createTransport({ + service: 'gmail', // You can use other services like 'outlook', 'yahoo', etc. + auth: { + user: process.env.EMAIL_USER, // Your email address + pass: process.env.EMAIL_PASSWORD, // Your email password or app password + }, +}); + +// ===== CORE EMAIL FUNCTION ===== +// Generic function to send any email (used by all other email functions) +export const sendEmail = async (to, subject, text, html) => { + try { + const mailOptions = { + from: process.env.EMAIL_USER, + to: to, + subject: subject, + text: text, // Plain text version (for email clients that don't support HTML) + html: html, // HTML version (for modern email clients) + }; + + const info = await transporter.sendMail(mailOptions); + console.log('Email sent successfully:', info.messageId); + return { success: true, messageId: info.messageId }; + } catch (error) { + console.error('Error sending email:', error); + return { success: false, error: error.message }; + } +}; + +// ===== CONNECTION NOTIFICATION EMAIL ===== +// Sent to the IDEA CREATOR when someone connects to their idea +export const sendConnectionNotification = async ( + ideaCreator, + connectingUser, + idea, + message, + socialLink +) => { + const subject = `New Connection to Your Idea: ${idea.title}`; + + // ===== PLAIN TEXT VERSION ===== + // Appears in email preview and text-only email clients + const text = ` +Hi ${ideaCreator.firstName}, + +${connectingUser.firstName} ${ + connectingUser.lastName + } has connected to your idea "${idea.title}". + +Message from ${connectingUser.firstName}: +"${message}" + +${socialLink ? `Connect with ${connectingUser.firstName}: ${socialLink}` : ''} + +You can view this connection in your profile. + +Best regards, +Your Pensive Team + `; + + // ===== HTML VERSION ===== + // Appears in modern email clients with full styling + const html = ` +
+ +
+
+ + + + + + + + +
+

Pensive

+

New Connection to Your Idea

+
+ + + +
+

${ + connectingUser.firstName + } ${ + connectingUser.lastName + } has connected to your idea "${idea.title}"!

+ + + +
+

Message from ${ + connectingUser.firstName + }:

+

"${message}"

+
+ + + ${ + socialLink + ? ` +
+

Connect with ${connectingUser.firstName}:

+ ${socialLink} +
+ ` + : '' + } +
+ + +

You can view and manage all your connections in your profile.

+ + + +
+

Best regards,
Your Pensive Team

+
+
+ `; + + return await sendEmail(ideaCreator.email, subject, text, html); +}; + +// ===== CONNECTION CONFIRMATION EMAIL ===== +// Sent to the CONNECTING USER to confirm their connection was sent +export const sendConnectionConfirmation = async ( + connectingUser, + ideaCreator, + idea +) => { + const subject = `Connection Sent: ${idea.title}`; + + // ===== PLAIN TEXT VERSION ===== + const text = ` +Hi ${connectingUser.firstName}, + +Your connection request to ${ideaCreator.firstName}'s idea "${idea.title}" has been sent successfully. + +${ideaCreator.firstName} will be notified and can respond to your connection. + +Best regards, +Your Platform Team + `; + + // ===== HTML VERSION ===== + const html = ` +
+ +
+
+ + + + + + + + +
+

Pensive

+

Connection Sent Successfully!

+
+ + + +
+

Your connection request has been sent to ${ideaCreator.firstName} ${ideaCreator.lastName}!

+

They will be notified and can respond to your connection.

+ + + +
+

Idea you connected to:

+

"${idea.title}"

+
+
+ + +

You can view all your connections in your profile.

+ + + +
+

Best regards,
Your Pensive Team

+
+
+ `; + + return await sendEmail(connectingUser.email, subject, text, html); +}; + +// ===== EXPORT ALL EMAIL FUNCTIONS ===== +export default { + sendEmail, + sendConnectionNotification, + sendConnectionConfirmation, +}; diff --git a/backend/uploads/files-1756305470242-404866381.jpg b/backend/uploads/files-1756305470242-404866381.jpg new file mode 100644 index 0000000000..4d99ef38ff Binary files /dev/null and b/backend/uploads/files-1756305470242-404866381.jpg differ diff --git a/backend/uploads/files-1756311835830-722375758.png b/backend/uploads/files-1756311835830-722375758.png new file mode 100644 index 0000000000..5fa697fbc6 Binary files /dev/null and b/backend/uploads/files-1756311835830-722375758.png differ diff --git a/backend/uploads/files-1756311999959-951576287.png b/backend/uploads/files-1756311999959-951576287.png new file mode 100644 index 0000000000..a080814212 Binary files /dev/null and b/backend/uploads/files-1756311999959-951576287.png differ diff --git a/backend/uploads/files-1756312246575-813003584.png b/backend/uploads/files-1756312246575-813003584.png new file mode 100644 index 0000000000..212049cde2 Binary files /dev/null and b/backend/uploads/files-1756312246575-813003584.png differ diff --git a/backend/uploads/files-1756312331494-272960053.png b/backend/uploads/files-1756312331494-272960053.png new file mode 100644 index 0000000000..a080814212 Binary files /dev/null and b/backend/uploads/files-1756312331494-272960053.png differ diff --git a/backend/uploads/files-1756312331495-187550296.png b/backend/uploads/files-1756312331495-187550296.png new file mode 100644 index 0000000000..25704df32f Binary files /dev/null and b/backend/uploads/files-1756312331495-187550296.png differ diff --git a/backend/uploads/files-1756312331498-850255212.png b/backend/uploads/files-1756312331498-850255212.png new file mode 100644 index 0000000000..5fa697fbc6 Binary files /dev/null and b/backend/uploads/files-1756312331498-850255212.png differ diff --git a/backend/uploads/files-1756369227186-381190890.png b/backend/uploads/files-1756369227186-381190890.png new file mode 100644 index 0000000000..5fa697fbc6 Binary files /dev/null and b/backend/uploads/files-1756369227186-381190890.png differ diff --git a/backend/uploads/files-1756369261081-876796635.png b/backend/uploads/files-1756369261081-876796635.png new file mode 100644 index 0000000000..212049cde2 Binary files /dev/null and b/backend/uploads/files-1756369261081-876796635.png differ diff --git a/backend/uploads/files-1756379907513-593860490.png b/backend/uploads/files-1756379907513-593860490.png new file mode 100644 index 0000000000..73ee8707fe Binary files /dev/null and b/backend/uploads/files-1756379907513-593860490.png differ diff --git a/backend/uploads/files-1756379956976-458845919.png b/backend/uploads/files-1756379956976-458845919.png new file mode 100644 index 0000000000..f95bd1b1eb Binary files /dev/null and b/backend/uploads/files-1756379956976-458845919.png differ diff --git a/backend/uploads/files-1756380170014-665563616.png b/backend/uploads/files-1756380170014-665563616.png new file mode 100644 index 0000000000..89a321dba0 Binary files /dev/null and b/backend/uploads/files-1756380170014-665563616.png differ diff --git a/backend/uploads/files-1756380366133-77116108.png b/backend/uploads/files-1756380366133-77116108.png new file mode 100644 index 0000000000..073dc9a1d8 Binary files /dev/null and b/backend/uploads/files-1756380366133-77116108.png differ diff --git a/backend/uploads/files-1756385702699-763539753.png b/backend/uploads/files-1756385702699-763539753.png new file mode 100644 index 0000000000..073dc9a1d8 Binary files /dev/null and b/backend/uploads/files-1756385702699-763539753.png differ diff --git a/backend/uploads/files-1756454094951-139705125.png b/backend/uploads/files-1756454094951-139705125.png new file mode 100644 index 0000000000..073dc9a1d8 Binary files /dev/null and b/backend/uploads/files-1756454094951-139705125.png differ diff --git a/frontend/dist/assets/index-1RaLYPb6.js b/frontend/dist/assets/index-1RaLYPb6.js new file mode 100644 index 0000000000..bc610bae42 --- /dev/null +++ b/frontend/dist/assets/index-1RaLYPb6.js @@ -0,0 +1,4575 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))n(i);new MutationObserver(i=>{for(const s of i)if(s.type==="childList")for(const l of s.addedNodes)l.tagName==="LINK"&&l.rel==="modulepreload"&&n(l)}).observe(document,{childList:!0,subtree:!0});function e(i){const s={};return i.integrity&&(s.integrity=i.integrity),i.referrerPolicy&&(s.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?s.credentials="include":i.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function n(i){if(i.ep)return;i.ep=!0;const s=e(i);fetch(i.href,s)}})();function Rg(a){return a&&a.__esModule&&Object.prototype.hasOwnProperty.call(a,"default")?a.default:a}var yS={exports:{}},m0={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var KT;function pN(){if(KT)return m0;KT=1;var a=Symbol.for("react.transitional.element"),t=Symbol.for("react.fragment");function e(n,i,s){var l=null;if(s!==void 0&&(l=""+s),i.key!==void 0&&(l=""+i.key),"key"in i){s={};for(var c in i)c!=="key"&&(s[c]=i[c])}else s=i;return i=s.ref,{$$typeof:a,type:n,key:l,ref:i!==void 0?i:null,props:s}}return m0.Fragment=t,m0.jsx=e,m0.jsxs=e,m0}var JT;function mN(){return JT||(JT=1,yS.exports=pN()),yS.exports}var rt=mN(),_S={exports:{}},rn={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var $T;function gN(){if($T)return rn;$T=1;var a=Symbol.for("react.transitional.element"),t=Symbol.for("react.portal"),e=Symbol.for("react.fragment"),n=Symbol.for("react.strict_mode"),i=Symbol.for("react.profiler"),s=Symbol.for("react.consumer"),l=Symbol.for("react.context"),c=Symbol.for("react.forward_ref"),d=Symbol.for("react.suspense"),p=Symbol.for("react.memo"),g=Symbol.for("react.lazy"),y=Symbol.iterator;function _(X){return X===null||typeof X!="object"?null:(X=y&&X[y]||X["@@iterator"],typeof X=="function"?X:null)}var x={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},E=Object.assign,T={};function A(X,at,yt){this.props=X,this.context=at,this.refs=T,this.updater=yt||x}A.prototype.isReactComponent={},A.prototype.setState=function(X,at){if(typeof X!="object"&&typeof X!="function"&&X!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,X,at,"setState")},A.prototype.forceUpdate=function(X){this.updater.enqueueForceUpdate(this,X,"forceUpdate")};function M(){}M.prototype=A.prototype;function R(X,at,yt){this.props=X,this.context=at,this.refs=T,this.updater=yt||x}var D=R.prototype=new M;D.constructor=R,E(D,A.prototype),D.isPureReactComponent=!0;var O=Array.isArray,z={H:null,A:null,T:null,S:null,V:null},B=Object.prototype.hasOwnProperty;function L(X,at,yt,Dt,Ot,dt){return yt=dt.ref,{$$typeof:a,type:X,key:at,ref:yt!==void 0?yt:null,props:dt}}function I(X,at){return L(X.type,at,void 0,void 0,void 0,X.props)}function N(X){return typeof X=="object"&&X!==null&&X.$$typeof===a}function P(X){var at={"=":"=0",":":"=2"};return"$"+X.replace(/[=:]/g,function(yt){return at[yt]})}var V=/\/+/g;function J(X,at){return typeof X=="object"&&X!==null&&X.key!=null?P(""+X.key):at.toString(36)}function K(){}function lt(X){switch(X.status){case"fulfilled":return X.value;case"rejected":throw X.reason;default:switch(typeof X.status=="string"?X.then(K,K):(X.status="pending",X.then(function(at){X.status==="pending"&&(X.status="fulfilled",X.value=at)},function(at){X.status==="pending"&&(X.status="rejected",X.reason=at)})),X.status){case"fulfilled":return X.value;case"rejected":throw X.reason}}throw X}function ft(X,at,yt,Dt,Ot){var dt=typeof X;(dt==="undefined"||dt==="boolean")&&(X=null);var Nt=!1;if(X===null)Nt=!0;else switch(dt){case"bigint":case"string":case"number":Nt=!0;break;case"object":switch(X.$$typeof){case a:case t:Nt=!0;break;case g:return Nt=X._init,ft(Nt(X._payload),at,yt,Dt,Ot)}}if(Nt)return Ot=Ot(X),Nt=Dt===""?"."+J(X,0):Dt,O(Ot)?(yt="",Nt!=null&&(yt=Nt.replace(V,"$&/")+"/"),ft(Ot,at,yt,"",function(se){return se})):Ot!=null&&(N(Ot)&&(Ot=I(Ot,yt+(Ot.key==null||X&&X.key===Ot.key?"":(""+Ot.key).replace(V,"$&/")+"/")+Nt)),at.push(Ot)),1;Nt=0;var Xt=Dt===""?".":Dt+":";if(O(X))for(var te=0;te>>1,X=F[ht];if(0>>1;hti(Dt,W))Oti(dt,Dt)?(F[ht]=dt,F[Ot]=W,ht=Ot):(F[ht]=Dt,F[yt]=W,ht=yt);else if(Oti(dt,W))F[ht]=dt,F[Ot]=W,ht=Ot;else break t}}return j}function i(F,j){var W=F.sortIndex-j.sortIndex;return W!==0?W:F.id-j.id}if(a.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var s=performance;a.unstable_now=function(){return s.now()}}else{var l=Date,c=l.now();a.unstable_now=function(){return l.now()-c}}var d=[],p=[],g=1,y=null,_=3,x=!1,E=!1,T=!1,A=!1,M=typeof setTimeout=="function"?setTimeout:null,R=typeof clearTimeout=="function"?clearTimeout:null,D=typeof setImmediate<"u"?setImmediate:null;function O(F){for(var j=e(p);j!==null;){if(j.callback===null)n(p);else if(j.startTime<=F)n(p),j.sortIndex=j.expirationTime,t(d,j);else break;j=e(p)}}function z(F){if(T=!1,O(F),!E)if(e(d)!==null)E=!0,B||(B=!0,J());else{var j=e(p);j!==null&&ft(z,j.startTime-F)}}var B=!1,L=-1,I=5,N=-1;function P(){return A?!0:!(a.unstable_now()-NF&&P());){var ht=y.callback;if(typeof ht=="function"){y.callback=null,_=y.priorityLevel;var X=ht(y.expirationTime<=F);if(F=a.unstable_now(),typeof X=="function"){y.callback=X,O(F),j=!0;break e}y===e(d)&&n(d),O(F)}else n(d);y=e(d)}if(y!==null)j=!0;else{var at=e(p);at!==null&&ft(z,at.startTime-F),j=!1}}break t}finally{y=null,_=W,x=!1}j=void 0}}finally{j?J():B=!1}}}var J;if(typeof D=="function")J=function(){D(V)};else if(typeof MessageChannel<"u"){var K=new MessageChannel,lt=K.port2;K.port1.onmessage=V,J=function(){lt.postMessage(null)}}else J=function(){M(V,0)};function ft(F,j){L=M(function(){F(a.unstable_now())},j)}a.unstable_IdlePriority=5,a.unstable_ImmediatePriority=1,a.unstable_LowPriority=4,a.unstable_NormalPriority=3,a.unstable_Profiling=null,a.unstable_UserBlockingPriority=2,a.unstable_cancelCallback=function(F){F.callback=null},a.unstable_forceFrameRate=function(F){0>F||125ht?(F.sortIndex=W,t(p,F),e(d)===null&&F===e(p)&&(T?(R(L),L=-1):T=!0,ft(z,W-ht))):(F.sortIndex=X,t(d,F),E||x||(E=!0,B||(B=!0,J()))),F},a.unstable_shouldYield=P,a.unstable_wrapCallback=function(F){var j=_;return function(){var W=_;_=j;try{return F.apply(this,arguments)}finally{_=W}}}}(bS)),bS}var nA;function yN(){return nA||(nA=1,SS.exports=vN()),SS.exports}var MS={exports:{}},Va={};/** + * @license React + * react-dom.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var iA;function _N(){if(iA)return Va;iA=1;var a=qp();function t(d){var p="https://react.dev/errors/"+d;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(a)}catch(t){console.error(t)}}return a(),MS.exports=_N(),MS.exports}/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var rA;function SN(){if(rA)return g0;rA=1;var a=yN(),t=qp(),e=xN();function n(r){var o="https://react.dev/errors/"+r;if(1X||(r.current=ht[X],ht[X]=null,X--)}function Dt(r,o){X++,ht[X]=r.current,r.current=o}var Ot=at(null),dt=at(null),Nt=at(null),Xt=at(null);function te(r,o){switch(Dt(Nt,o),Dt(dt,r),Dt(Ot,null),o.nodeType){case 9:case 11:r=(r=o.documentElement)&&(r=r.namespaceURI)?TT(r):0;break;default:if(r=o.tagName,o=o.namespaceURI)o=TT(o),r=AT(o,r);else switch(r){case"svg":r=1;break;case"math":r=2;break;default:r=0}}yt(Ot),Dt(Ot,r)}function se(){yt(Ot),yt(dt),yt(Nt)}function Te(r){r.memoizedState!==null&&Dt(Xt,r);var o=Ot.current,h=AT(o,r.type);o!==h&&(Dt(dt,r),Dt(Ot,h))}function an(r){dt.current===r&&(yt(Ot),yt(dt)),Xt.current===r&&(yt(Xt),c0._currentValue=W)}var xe=Object.prototype.hasOwnProperty,et=a.unstable_scheduleCallback,Vt=a.unstable_cancelCallback,Lt=a.unstable_shouldYield,jt=a.unstable_requestPaint,Ut=a.unstable_now,ie=a.unstable_getCurrentPriorityLevel,Yt=a.unstable_ImmediatePriority,Jt=a.unstable_UserBlockingPriority,we=a.unstable_NormalPriority,Le=a.unstable_LowPriority,Q=a.unstable_IdlePriority,G=a.log,vt=a.unstable_setDisableYieldValue,Ct=null,Bt=null;function Rt(r){if(typeof G=="function"&&vt(r),Bt&&typeof Bt.setStrictMode=="function")try{Bt.setStrictMode(Ct,r)}catch{}}var ue=Math.clz32?Math.clz32:ye,ne=Math.log,ve=Math.LN2;function ye(r){return r>>>=0,r===0?32:31-(ne(r)/ve|0)|0}var qt=256,ae=4194304;function _e(r){var o=r&42;if(o!==0)return o;switch(r&-r){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return r&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return r&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return r}}function $(r,o,h){var m=r.pendingLanes;if(m===0)return 0;var S=0,w=r.suspendedLanes,H=r.pingedLanes;r=r.warmLanes;var k=m&134217727;return k!==0?(m=k&~w,m!==0?S=_e(m):(H&=k,H!==0?S=_e(H):h||(h=k&~r,h!==0&&(S=_e(h))))):(k=m&~w,k!==0?S=_e(k):H!==0?S=_e(H):h||(h=m&~r,h!==0&&(S=_e(h)))),S===0?0:o!==0&&o!==S&&(o&w)===0&&(w=S&-S,h=o&-o,w>=h||w===32&&(h&4194048)!==0)?o:S}function xt(r,o){return(r.pendingLanes&~(r.suspendedLanes&~r.pingedLanes)&o)===0}function Qt(r,o){switch(r){case 1:case 2:case 4:case 8:case 64:return o+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return o+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function it(){var r=qt;return qt<<=1,(qt&4194048)===0&&(qt=256),r}function Gt(){var r=ae;return ae<<=1,(ae&62914560)===0&&(ae=4194304),r}function Zt(r){for(var o=[],h=0;31>h;h++)o.push(r);return o}function oe(r,o){r.pendingLanes|=o,o!==268435456&&(r.suspendedLanes=0,r.pingedLanes=0,r.warmLanes=0)}function Wt(r,o,h,m,S,w){var H=r.pendingLanes;r.pendingLanes=h,r.suspendedLanes=0,r.pingedLanes=0,r.warmLanes=0,r.expiredLanes&=h,r.entangledLanes&=h,r.errorRecoveryDisabledLanes&=h,r.shellSuspendCounter=0;var k=r.entanglements,tt=r.expirationTimes,bt=r.hiddenUpdates;for(h=H&~h;0)":-1S||tt[m]!==bt[S]){var Pt=` +`+tt[m].replace(" at new "," at ");return r.displayName&&Pt.includes("")&&(Pt=Pt.replace("",r.displayName)),Pt}while(1<=m&&0<=S);break}}}finally{Qe=!1,Error.prepareStackTrace=h}return(h=r?r.displayName||r.name:"")?Me(h):""}function jn(r){switch(r.tag){case 26:case 27:case 5:return Me(r.type);case 16:return Me("Lazy");case 13:return Me("Suspense");case 19:return Me("SuspenseList");case 0:case 15:return mn(r.type,!1);case 11:return mn(r.type.render,!1);case 1:return mn(r.type,!0);case 31:return Me("Activity");default:return""}}function Rn(r){try{var o="";do o+=jn(r),r=r.return;while(r);return o}catch(h){return` +Error generating stack: `+h.message+` +`+h.stack}}function je(r){switch(typeof r){case"bigint":case"boolean":case"number":case"string":case"undefined":return r;case"object":return r;default:return""}}function Re(r){var o=r.type;return(r=r.nodeName)&&r.toLowerCase()==="input"&&(o==="checkbox"||o==="radio")}function Wn(r){var o=Re(r)?"checked":"value",h=Object.getOwnPropertyDescriptor(r.constructor.prototype,o),m=""+r[o];if(!r.hasOwnProperty(o)&&typeof h<"u"&&typeof h.get=="function"&&typeof h.set=="function"){var S=h.get,w=h.set;return Object.defineProperty(r,o,{configurable:!0,get:function(){return S.call(this)},set:function(H){m=""+H,w.call(this,H)}}),Object.defineProperty(r,o,{enumerable:h.enumerable}),{getValue:function(){return m},setValue:function(H){m=""+H},stopTracking:function(){r._valueTracker=null,delete r[o]}}}}function on(r){r._valueTracker||(r._valueTracker=Wn(r))}function Zi(r){if(!r)return!1;var o=r._valueTracker;if(!o)return!0;var h=o.getValue(),m="";return r&&(m=Re(r)?r.checked?"true":"false":r.value),r=m,r!==h?(o.setValue(r),!0):!1}function Er(r){if(r=r||(typeof document<"u"?document:void 0),typeof r>"u")return null;try{return r.activeElement||r.body}catch{return r.body}}var Qi=/[\n"\\]/g;function Ci(r){return r.replace(Qi,function(o){return"\\"+o.charCodeAt(0).toString(16)+" "})}function On(r,o,h,m,S,w,H,k){r.name="",H!=null&&typeof H!="function"&&typeof H!="symbol"&&typeof H!="boolean"?r.type=H:r.removeAttribute("type"),o!=null?H==="number"?(o===0&&r.value===""||r.value!=o)&&(r.value=""+je(o)):r.value!==""+je(o)&&(r.value=""+je(o)):H!=="submit"&&H!=="reset"||r.removeAttribute("value"),o!=null?Ki(r,H,je(o)):h!=null?Ki(r,H,je(h)):m!=null&&r.removeAttribute("value"),S==null&&w!=null&&(r.defaultChecked=!!w),S!=null&&(r.checked=S&&typeof S!="function"&&typeof S!="symbol"),k!=null&&typeof k!="function"&&typeof k!="symbol"&&typeof k!="boolean"?r.name=""+je(k):r.removeAttribute("name")}function fa(r,o,h,m,S,w,H,k){if(w!=null&&typeof w!="function"&&typeof w!="symbol"&&typeof w!="boolean"&&(r.type=w),o!=null||h!=null){if(!(w!=="submit"&&w!=="reset"||o!=null))return;h=h!=null?""+je(h):"",o=o!=null?""+je(o):h,k||o===r.value||(r.value=o),r.defaultValue=o}m=m??S,m=typeof m!="function"&&typeof m!="symbol"&&!!m,r.checked=k?r.checked:!!m,r.defaultChecked=!!m,H!=null&&typeof H!="function"&&typeof H!="symbol"&&typeof H!="boolean"&&(r.name=H)}function Ki(r,o,h){o==="number"&&Er(r.ownerDocument)===r||r.defaultValue===""+h||(r.defaultValue=""+h)}function ei(r,o,h,m){if(r=r.options,o){o={};for(var S=0;S"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),$c=!1;if(Ms)try{var Du={};Object.defineProperty(Du,"passive",{get:function(){$c=!0}}),window.addEventListener("test",Du,Du),window.removeEventListener("test",Du,Du)}catch{$c=!1}var $s=null,rm=null,Bh=null;function sm(){if(Bh)return Bh;var r,o=rm,h=o.length,m,S="value"in $s?$s.value:$s.textContent,w=S.length;for(r=0;r=zl),Io=" ",ta=!1;function of(r,o){switch(r){case"keyup":return no.indexOf(o.keyCode)!==-1;case"keydown":return o.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function dm(r){return r=r.detail,typeof r=="object"&&"data"in r?r.data:null}var Ho=!1;function lf(r,o){switch(r){case"compositionend":return dm(o);case"keypress":return o.which!==32?null:(ta=!0,Io);case"textInput":return r=o.data,r===Io&&ta?null:r;default:return null}}function pm(r,o){if(Ho)return r==="compositionend"||!af&&of(r,o)?(r=sm(),Bh=rm=$s=null,Ho=!1,r):null;switch(r){case"paste":return null;case"keypress":if(!(o.ctrlKey||o.altKey||o.metaKey)||o.ctrlKey&&o.altKey){if(o.char&&1=o)return{node:h,offset:o-r};r=m}t:{for(;h;){if(h.nextSibling){h=h.nextSibling;break t}h=h.parentNode}h=void 0}h=za(h)}}function vm(r,o){return r&&o?r===o?!0:r&&r.nodeType===3?!1:o&&o.nodeType===3?vm(r,o.parentNode):"contains"in r?r.contains(o):r.compareDocumentPosition?!!(r.compareDocumentPosition(o)&16):!1:!1}function ym(r){r=r!=null&&r.ownerDocument!=null&&r.ownerDocument.defaultView!=null?r.ownerDocument.defaultView:window;for(var o=Er(r.document);o instanceof r.HTMLIFrameElement;){try{var h=typeof o.contentWindow.location.href=="string"}catch{h=!1}if(h)r=o.contentWindow;else break;o=Er(r.document)}return o}function ff(r){var o=r&&r.nodeName&&r.nodeName.toLowerCase();return o&&(o==="input"&&(r.type==="text"||r.type==="search"||r.type==="tel"||r.type==="url"||r.type==="password")||o==="textarea"||r.contentEditable==="true")}var _m=Ms&&"documentMode"in document&&11>=document.documentMode,io=null,qh=null,hf=null,Yh=!1;function xm(r,o,h){var m=h.window===h?h.document:h.nodeType===9?h:h.ownerDocument;Yh||io==null||io!==Er(m)||(m=io,"selectionStart"in m&&ff(m)?m={start:m.selectionStart,end:m.selectionEnd}:(m=(m.ownerDocument&&m.ownerDocument.defaultView||window).getSelection(),m={anchorNode:m.anchorNode,anchorOffset:m.anchorOffset,focusNode:m.focusNode,focusOffset:m.focusOffset}),hf&&Jr(hf,m)||(hf=m,m=ay(qh,"onSelect"),0>=H,S-=H,Es=1<<32-ue(o)+S|h<w?w:8;var H=F.T,k={};F.T=k,pd(r,!1,o,h);try{var tt=S(),bt=F.S;if(bt!==null&&bt(k,tt),tt!==null&&typeof tt=="object"&&typeof tt.then=="function"){var Pt=F1(tt,m);Rf(r,o,Pt,li(r))}else Rf(r,o,m,li(r))}catch(kt){Rf(r,o,{then:function(){},status:"rejected",reason:kt},li())}finally{j.p=w,F.T=H}}function X1(){}function Qm(r,o,h,m){if(r.tag!==5)throw Error(n(476));var S=Hv(r).queue;Iv(r,S,o,W,h===null?X1:function(){return Fv(r),h(m)})}function Hv(r){var o=r.memoizedState;if(o!==null)return o;o={memoizedState:W,baseState:W,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:go,lastRenderedState:W},next:null};var h={};return o.next={memoizedState:h,baseState:h,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:go,lastRenderedState:h},next:null},r.memoizedState=o,r=r.alternate,r!==null&&(r.memoizedState=o),o}function Fv(r){var o=Hv(r).next.queue;Rf(r,o,{},li())}function Km(){return ri(c0)}function Vv(){return Ei().memoizedState}function Gv(){return Ei().memoizedState}function q1(r){for(var o=r.return;o!==null;){switch(o.tag){case 24:case 3:var h=li();r=ws(h);var m=ho(o,r,h);m!==null&&(Ca(m,o,h),Il(m,o,h)),o={cache:ed()},r.payload=o;return}o=o.return}}function Y1(r,o,h){var m=li();h={lane:m,revertLane:0,action:h,hasEagerState:!1,eagerState:null,next:null},md(r)?Xv(o,h):(h=Qh(r,o,h,m),h!==null&&(Ca(h,r,m),rr(h,o,m)))}function kv(r,o,h){var m=li();Rf(r,o,h,m)}function Rf(r,o,h,m){var S={lane:m,revertLane:0,action:h,hasEagerState:!1,eagerState:null,next:null};if(md(r))Xv(o,S);else{var w=r.alternate;if(r.lanes===0&&(w===null||w.lanes===0)&&(w=o.lastRenderedReducer,w!==null))try{var H=o.lastRenderedState,k=w(H,h);if(S.hasEagerState=!0,S.eagerState=k,La(k,H))return Go(r,o,S,0),Xe===null&&pf(),!1}catch{}finally{}if(h=Qh(r,o,S,m),h!==null)return Ca(h,r,m),rr(h,o,m),!0}return!1}function pd(r,o,h,m){if(m={lane:2,revertLane:Z1(),action:m,hasEagerState:!1,eagerState:null,next:null},md(r)){if(o)throw Error(n(479))}else o=Qh(r,h,m,2),o!==null&&Ca(o,r,2)}function md(r){var o=r.alternate;return r===Ze||o!==null&&o===Ze}function Xv(r,o){Qo=od=!0;var h=r.pending;h===null?o.next=o:(o.next=h.next,h.next=o),r.pending=o}function rr(r,o,h){if((h&4194048)!==0){var m=o.lanes;m&=r.pendingLanes,h|=m,o.lanes=h,ce(r,h)}}var Df={readContext:ri,use:ud,useCallback:fi,useContext:fi,useEffect:fi,useImperativeHandle:fi,useLayoutEffect:fi,useInsertionEffect:fi,useMemo:fi,useReducer:fi,useRef:fi,useState:fi,useDebugValue:fi,useDeferredValue:fi,useTransition:fi,useSyncExternalStore:fi,useId:fi,useHostTransitionStatus:fi,useFormState:fi,useActionState:fi,useOptimistic:fi,useMemoCache:fi,useCacheRefresh:fi},gd={readContext:ri,use:ud,useCallback:function(r,o){return Ta().memoizedState=[r,o===void 0?null:o],r},useContext:ri,useEffect:Ym,useImperativeHandle:function(r,o,h){h=h!=null?h.concat([r]):null,cd(4194308,4,Lv.bind(null,o,r),h)},useLayoutEffect:function(r,o){return cd(4194308,4,r,o)},useInsertionEffect:function(r,o){cd(4,2,r,o)},useMemo:function(r,o){var h=Ta();o=o===void 0?null:o;var m=r();if(Ea){Rt(!0);try{r()}finally{Rt(!1)}}return h.memoizedState=[m,o],m},useReducer:function(r,o,h){var m=Ta();if(h!==void 0){var S=h(o);if(Ea){Rt(!0);try{h(o)}finally{Rt(!1)}}}else S=o;return m.memoizedState=m.baseState=S,r={pending:null,lanes:0,dispatch:null,lastRenderedReducer:r,lastRenderedState:S},m.queue=r,r=r.dispatch=Y1.bind(null,Ze,r),[m.memoizedState,r]},useRef:function(r){var o=Ta();return r={current:r},o.memoizedState=r},useState:function(r){r=Gm(r);var o=r.queue,h=kv.bind(null,Ze,o);return o.dispatch=h,[r.memoizedState,h]},useDebugValue:Wm,useDeferredValue:function(r,o){var h=Ta();return dd(h,r,o)},useTransition:function(){var r=Gm(!1);return r=Iv.bind(null,Ze,r.queue,!0,!1),Ta().memoizedState=r,[!1,r]},useSyncExternalStore:function(r,o,h){var m=Ze,S=Ta();if(xn){if(h===void 0)throw Error(n(407));h=h()}else{if(h=o(),Xe===null)throw Error(n(349));(Oe&124)!==0||$u(m,o,h)}S.memoizedState=h;var w={value:h,getSnapshot:o};return S.queue=w,Ym(bv.bind(null,m,w,r),[r]),m.flags|=2048,tc(9,Cf(),Sv.bind(null,m,w,h,o),null),h},useId:function(){var r=Ta(),o=Xe.identifierPrefix;if(xn){var h=Ts,m=Es;h=(m&~(1<<32-ue(m)-1)).toString(32)+h,o="«"+o+"R"+h,h=Di++,0Ie?(ga=De,De=null):ga=De.sibling;var Cn=Et(mt,De,St[Ie],Ft);if(Cn===null){De===null&&(De=ga);break}r&&De&&Cn.alternate===null&&o(mt,De),ut=w(Cn,ut,Ie),hn===null?Se=Cn:hn.sibling=Cn,hn=Cn,De=ga}if(Ie===St.length)return h(mt,De),xn&&lo(mt,Ie),Se;if(De===null){for(;IeIe?(ga=De,De=null):ga=De.sibling;var _c=Et(mt,De,Cn.value,Ft);if(_c===null){De===null&&(De=ga);break}r&&De&&_c.alternate===null&&o(mt,De),ut=w(_c,ut,Ie),hn===null?Se=_c:hn.sibling=_c,hn=_c,De=ga}if(Cn.done)return h(mt,De),xn&&lo(mt,Ie),Se;if(De===null){for(;!Cn.done;Ie++,Cn=St.next())Cn=kt(mt,Cn.value,Ft),Cn!==null&&(ut=w(Cn,ut,Ie),hn===null?Se=Cn:hn.sibling=Cn,hn=Cn);return xn&&lo(mt,Ie),Se}for(De=m(De);!Cn.done;Ie++,Cn=St.next())Cn=At(De,mt,Ie,Cn.value,Ft),Cn!==null&&(r&&Cn.alternate!==null&&De.delete(Cn.key===null?Ie:Cn.key),ut=w(Cn,ut,Ie),hn===null?Se=Cn:hn.sibling=Cn,hn=Cn);return r&&De.forEach(function(dN){return o(mt,dN)}),xn&&lo(mt,Ie),Se}function Xn(mt,ut,St,Ft){if(typeof St=="object"&&St!==null&&St.type===E&&St.key===null&&(St=St.props.children),typeof St=="object"&&St!==null){switch(St.$$typeof){case _:t:{for(var Se=St.key;ut!==null;){if(ut.key===Se){if(Se=St.type,Se===E){if(ut.tag===7){h(mt,ut.sibling),Ft=S(ut,St.props.children),Ft.return=mt,mt=Ft;break t}}else if(ut.elementType===Se||typeof Se=="object"&&Se!==null&&Se.$$typeof===I&&Yv(Se)===ut.type){h(mt,ut.sibling),Ft=S(ut,St.props),nc(Ft,St),Ft.return=mt,mt=Ft;break t}h(mt,ut);break}else o(mt,ut);ut=ut.sibling}St.type===E?(Ft=Xo(St.props.children,mt.mode,Ft,St.key),Ft.return=mt,mt=Ft):(Ft=Fi(St.type,St.key,St.props,null,mt.mode,Ft),nc(Ft,St),Ft.return=mt,mt=Ft)}return H(mt);case x:t:{for(Se=St.key;ut!==null;){if(ut.key===Se)if(ut.tag===4&&ut.stateNode.containerInfo===St.containerInfo&&ut.stateNode.implementation===St.implementation){h(mt,ut.sibling),Ft=S(ut,St.children||[]),Ft.return=mt,mt=Ft;break t}else{h(mt,ut);break}else o(mt,ut);ut=ut.sibling}Ft=Kh(St,mt.mode,Ft),Ft.return=mt,mt=Ft}return H(mt);case I:return Se=St._init,St=Se(St._payload),Xn(mt,ut,St,Ft)}if(ft(St))return Ge(mt,ut,St,Ft);if(J(St)){if(Se=J(St),typeof Se!="function")throw Error(n(150));return St=Se.call(St),Be(mt,ut,St,Ft)}if(typeof St.then=="function")return Xn(mt,ut,Nf(St),Ft);if(St.$$typeof===D)return Xn(mt,ut,xf(mt,St),Ft);vd(mt,St)}return typeof St=="string"&&St!==""||typeof St=="number"||typeof St=="bigint"?(St=""+St,ut!==null&&ut.tag===6?(h(mt,ut.sibling),Ft=S(ut,St),Ft.return=mt,mt=Ft):(h(mt,ut),Ft=gf(St,mt.mode,Ft),Ft.return=mt,mt=Ft),H(mt)):h(mt,ut)}return function(mt,ut,St,Ft){try{Of=0;var Se=Xn(mt,ut,St,Ft);return Hl=null,Se}catch(De){if(De===Ef||De===ts)throw De;var hn=Ba(29,De,null,mt.mode);return hn.lanes=Ft,hn.return=mt,hn}finally{}}}var Fl=jv(!0),Wv=jv(!1),Ln=at(null),ns=null;function $o(r){var o=r.alternate;Dt(hi,hi.current&1),Dt(Ln,r),ns===null&&(o===null||Ju.current!==null||o.memoizedState!==null)&&(ns=r)}function Zv(r){if(r.tag===22){if(Dt(hi,hi.current),Dt(Ln,r),ns===null){var o=r.alternate;o!==null&&o.memoizedState!==null&&(ns=r)}}else tl()}function tl(){Dt(hi,hi.current),Dt(Ln,Ln.current)}function Cs(r){yt(Ln),ns===r&&(ns=null),yt(hi)}var hi=at(0);function vo(r){for(var o=r;o!==null;){if(o.tag===13){var h=o.memoizedState;if(h!==null&&(h=h.dehydrated,h===null||h.data==="$?"||oS(h)))return o}else if(o.tag===19&&o.memoizedProps.revealOrder!==void 0){if((o.flags&128)!==0)return o}else if(o.child!==null){o.child.return=o,o=o.child;continue}if(o===r)break;for(;o.sibling===null;){if(o.return===null||o.return===r)return null;o=o.return}o.sibling.return=o.return,o=o.sibling}return null}function el(r,o,h,m){o=r.memoizedState,h=h(m,o),h=h==null?o:g({},o,h),r.memoizedState=h,r.lanes===0&&(r.updateQueue.baseState=h)}var Aa={enqueueSetState:function(r,o,h){r=r._reactInternals;var m=li(),S=ws(m);S.payload=o,h!=null&&(S.callback=h),o=ho(r,S,m),o!==null&&(Ca(o,r,m),Il(o,r,m))},enqueueReplaceState:function(r,o,h){r=r._reactInternals;var m=li(),S=ws(m);S.tag=1,S.payload=o,h!=null&&(S.callback=h),o=ho(r,S,m),o!==null&&(Ca(o,r,m),Il(o,r,m))},enqueueForceUpdate:function(r,o){r=r._reactInternals;var h=li(),m=ws(h);m.tag=2,o!=null&&(m.callback=o),o=ho(r,m,h),o!==null&&(Ca(o,r,h),Il(o,r,h))}};function Qv(r,o,h,m,S,w,H){return r=r.stateNode,typeof r.shouldComponentUpdate=="function"?r.shouldComponentUpdate(m,w,H):o.prototype&&o.prototype.isPureReactComponent?!Jr(h,m)||!Jr(S,w):!0}function Kv(r,o,h,m){r=o.state,typeof o.componentWillReceiveProps=="function"&&o.componentWillReceiveProps(h,m),typeof o.UNSAFE_componentWillReceiveProps=="function"&&o.UNSAFE_componentWillReceiveProps(h,m),o.state!==r&&Aa.enqueueReplaceState(o,o.state,null)}function Rs(r,o){var h=o;if("ref"in o){h={};for(var m in o)m!=="ref"&&(h[m]=o[m])}if(r=r.defaultProps){h===o&&(h=g({},h));for(var S in r)h[S]===void 0&&(h[S]=r[S])}return h}var Vl=typeof reportError=="function"?reportError:function(r){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var o=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof r=="object"&&r!==null&&typeof r.message=="string"?String(r.message):String(r),error:r});if(!window.dispatchEvent(o))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",r);return}console.error(r)};function Uf(r){Vl(r)}function yd(r){console.error(r)}function Jv(r){Vl(r)}function _d(r,o){try{var h=r.onUncaughtError;h(o.value,{componentStack:o.stack})}catch(m){setTimeout(function(){throw m})}}function Or(r,o,h){try{var m=r.onCaughtError;m(h.value,{componentStack:h.stack,errorBoundary:o.tag===1?o.stateNode:null})}catch(S){setTimeout(function(){throw S})}}function xd(r,o,h){return h=ws(h),h.tag=3,h.payload={element:null},h.callback=function(){_d(r,o)},h}function $v(r){return r=ws(r),r.tag=3,r}function Sd(r,o,h,m){var S=h.type.getDerivedStateFromError;if(typeof S=="function"){var w=m.value;r.payload=function(){return S(w)},r.callback=function(){Or(o,h,m)}}var H=h.stateNode;H!==null&&typeof H.componentDidCatch=="function"&&(r.callback=function(){Or(o,h,m),typeof S!="function"&&(dr===null?dr=new Set([this]):dr.add(this));var k=m.stack;this.componentDidCatch(m.value,{componentStack:k!==null?k:""})})}function j1(r,o,h,m,S){if(h.flags|=32768,m!==null&&typeof m=="object"&&typeof m.then=="function"){if(o=h.alternate,o!==null&&wr(o,h,S,!0),h=Ln.current,h!==null){switch(h.tag){case 13:return ns===null?u():h.alternate===null&&Zn===0&&(Zn=3),h.flags&=-257,h.flags|=65536,h.lanes=S,m===Tf?h.flags|=16384:(o=h.updateQueue,o===null?h.updateQueue=new Set([m]):o.add(m),wt(r,m,S)),!1;case 22:return h.flags|=65536,m===Tf?h.flags|=16384:(o=h.updateQueue,o===null?(o={transitions:null,markerInstances:null,retryQueue:new Set([m])},h.updateQueue=o):(h=o.retryQueue,h===null?o.retryQueue=new Set([m]):h.add(m)),wt(r,m,S)),!1}throw Error(n(435,h.tag))}return wt(r,m,S),u(),!1}if(xn)return o=Ln.current,o!==null?((o.flags&65536)===0&&(o.flags|=256),o.flags|=65536,o.lanes=S,m!==$h&&(r=Error(n(422),{cause:m}),Bl(na(r,h)))):(m!==$h&&(o=Error(n(423),{cause:m}),Bl(na(o,h))),r=r.current.alternate,r.flags|=65536,S&=-S,r.lanes|=S,m=na(m,h),S=xd(r.stateNode,m,S),Zu(r,S),Zn!==4&&(Zn=2)),!1;var w=Error(n(520),{cause:m});if(w=na(w,h),wn===null?wn=[w]:wn.push(w),Zn!==4&&(Zn=2),o===null)return!0;m=na(m,h),h=o;do{switch(h.tag){case 3:return h.flags|=65536,r=S&-S,h.lanes|=r,r=xd(h.stateNode,m,r),Zu(h,r),!1;case 1:if(o=h.type,w=h.stateNode,(h.flags&128)===0&&(typeof o.getDerivedStateFromError=="function"||w!==null&&typeof w.componentDidCatch=="function"&&(dr===null||!dr.has(w))))return h.flags|=65536,S&=-S,h.lanes|=S,S=$v(S),Sd(S,r,h,m),Zu(h,S),!1}h=h.return}while(h!==null);return!1}var ty=Error(n(461)),Vi=!1;function di(r,o,h,m){o.child=r===null?Wv(o,null,h,m):Fl(o,r.child,h,m)}function Ha(r,o,h,m,S){h=h.render;var w=o.ref;if("ref"in m){var H={};for(var k in m)k!=="ref"&&(H[k]=m[k])}else H=m;return As(o),m=mo(r,o,h,H,w,S),k=Bm(),r!==null&&!Vi?(ld(r,o,S),Gi(r,o,S)):(xn&&k&&Am(o),o.flags|=1,di(r,o,m,S),o.child)}function Fa(r,o,h,m,S){if(r===null){var w=h.type;return typeof w=="function"&&!mf(w)&&w.defaultProps===void 0&&h.compare===null?(o.tag=15,o.type=w,$m(r,o,w,m,S)):(r=Fi(h.type,null,m,o,o.mode,S),r.ref=o.ref,r.return=o,o.child=r)}if(w=r.child,!Lf(r,S)){var H=w.memoizedProps;if(h=h.compare,h=h!==null?h:Jr,h(H,m)&&r.ref===o.ref)return Gi(r,o,S)}return o.flags|=1,r=ci(w,m),r.ref=o.ref,r.return=o,o.child=r}function $m(r,o,h,m,S){if(r!==null){var w=r.memoizedProps;if(Jr(w,m)&&r.ref===o.ref)if(Vi=!1,o.pendingProps=m=w,Lf(r,S))(r.flags&131072)!==0&&(Vi=!0);else return o.lanes=r.lanes,Gi(r,o,S)}return ic(r,o,h,m,S)}function Gl(r,o,h){var m=o.pendingProps,S=m.children,w=r!==null?r.memoizedState:null;if(m.mode==="hidden"){if((o.flags&128)!==0){if(m=w!==null?w.baseLanes|h:h,r!==null){for(S=o.child=r.child,w=0;S!==null;)w=w|S.lanes|S.childLanes,S=S.sibling;o.childLanes=w&~m}else o.childLanes=0,o.child=null;return kl(r,o,m,h)}if((h&536870912)!==0)o.memoizedState={baseLanes:0,cachePool:null},r!==null&&Mf(o,w!==null?w.cachePool:null),w!==null?gv(o,w):Pm(),Zv(o);else return o.lanes=o.childLanes=536870912,kl(r,o,w!==null?w.baseLanes|h:h,h)}else w!==null?(Mf(o,w.cachePool),gv(o,w),tl(),o.memoizedState=null):(r!==null&&Mf(o,null),Pm(),tl());return di(r,o,S,h),o.child}function kl(r,o,h,m){var S=Zo();return S=S===null?null:{parent:bi._currentValue,pool:S},o.memoizedState={baseLanes:h,cachePool:S},r!==null&&Mf(o,null),Pm(),Zv(o),r!==null&&wr(r,o,m,!0),null}function Xl(r,o){var h=o.ref;if(h===null)r!==null&&r.ref!==null&&(o.flags|=4194816);else{if(typeof h!="function"&&typeof h!="object")throw Error(n(284));(r===null||r.ref!==h)&&(o.flags|=4194816)}}function ic(r,o,h,m,S){return As(o),h=mo(r,o,h,m,void 0,S),m=Bm(),r!==null&&!Vi?(ld(r,o,S),Gi(r,o,S)):(xn&&m&&Am(o),o.flags|=1,di(r,o,h,S),o.child)}function sr(r,o,h,m,S,w){return As(o),o.updateQueue=null,h=_v(o,m,h,S),yv(r),m=Bm(),r!==null&&!Vi?(ld(r,o,w),Gi(r,o,w)):(xn&&m&&Am(o),o.flags|=1,di(r,o,h,w),o.child)}function or(r,o,h,m,S){if(As(o),o.stateNode===null){var w=ko,H=h.contextType;typeof H=="object"&&H!==null&&(w=ri(H)),w=new h(m,w),o.memoizedState=w.state!==null&&w.state!==void 0?w.state:null,w.updater=Aa,o.stateNode=w,w._reactInternals=o,w=o.stateNode,w.props=m,w.state=o.memoizedState,w.refs={},Lm(o),H=h.contextType,w.context=typeof H=="object"&&H!==null?ri(H):ko,w.state=o.memoizedState,H=h.getDerivedStateFromProps,typeof H=="function"&&(el(o,h,H,m),w.state=o.memoizedState),typeof h.getDerivedStateFromProps=="function"||typeof w.getSnapshotBeforeUpdate=="function"||typeof w.UNSAFE_componentWillMount!="function"&&typeof w.componentWillMount!="function"||(H=w.state,typeof w.componentWillMount=="function"&&w.componentWillMount(),typeof w.UNSAFE_componentWillMount=="function"&&w.UNSAFE_componentWillMount(),H!==w.state&&Aa.enqueueReplaceState(w,w.state,null),Qu(o,m,w,S),Af(),w.state=o.memoizedState),typeof w.componentDidMount=="function"&&(o.flags|=4194308),m=!0}else if(r===null){w=o.stateNode;var k=o.memoizedProps,tt=Rs(h,k);w.props=tt;var bt=w.context,Pt=h.contextType;H=ko,typeof Pt=="object"&&Pt!==null&&(H=ri(Pt));var kt=h.getDerivedStateFromProps;Pt=typeof kt=="function"||typeof w.getSnapshotBeforeUpdate=="function",k=o.pendingProps!==k,Pt||typeof w.UNSAFE_componentWillReceiveProps!="function"&&typeof w.componentWillReceiveProps!="function"||(k||bt!==H)&&Kv(o,w,m,H),fo=!1;var Et=o.memoizedState;w.state=Et,Qu(o,m,w,S),Af(),bt=o.memoizedState,k||Et!==bt||fo?(typeof kt=="function"&&(el(o,h,kt,m),bt=o.memoizedState),(tt=fo||Qv(o,h,tt,m,Et,bt,H))?(Pt||typeof w.UNSAFE_componentWillMount!="function"&&typeof w.componentWillMount!="function"||(typeof w.componentWillMount=="function"&&w.componentWillMount(),typeof w.UNSAFE_componentWillMount=="function"&&w.UNSAFE_componentWillMount()),typeof w.componentDidMount=="function"&&(o.flags|=4194308)):(typeof w.componentDidMount=="function"&&(o.flags|=4194308),o.memoizedProps=m,o.memoizedState=bt),w.props=m,w.state=bt,w.context=H,m=tt):(typeof w.componentDidMount=="function"&&(o.flags|=4194308),m=!1)}else{w=o.stateNode,He(r,o),H=o.memoizedProps,Pt=Rs(h,H),w.props=Pt,kt=o.pendingProps,Et=w.context,bt=h.contextType,tt=ko,typeof bt=="object"&&bt!==null&&(tt=ri(bt)),k=h.getDerivedStateFromProps,(bt=typeof k=="function"||typeof w.getSnapshotBeforeUpdate=="function")||typeof w.UNSAFE_componentWillReceiveProps!="function"&&typeof w.componentWillReceiveProps!="function"||(H!==kt||Et!==tt)&&Kv(o,w,m,tt),fo=!1,Et=o.memoizedState,w.state=Et,Qu(o,m,w,S),Af();var At=o.memoizedState;H!==kt||Et!==At||fo||r!==null&&r.dependencies!==null&&_f(r.dependencies)?(typeof k=="function"&&(el(o,h,k,m),At=o.memoizedState),(Pt=fo||Qv(o,h,Pt,m,Et,At,tt)||r!==null&&r.dependencies!==null&&_f(r.dependencies))?(bt||typeof w.UNSAFE_componentWillUpdate!="function"&&typeof w.componentWillUpdate!="function"||(typeof w.componentWillUpdate=="function"&&w.componentWillUpdate(m,At,tt),typeof w.UNSAFE_componentWillUpdate=="function"&&w.UNSAFE_componentWillUpdate(m,At,tt)),typeof w.componentDidUpdate=="function"&&(o.flags|=4),typeof w.getSnapshotBeforeUpdate=="function"&&(o.flags|=1024)):(typeof w.componentDidUpdate!="function"||H===r.memoizedProps&&Et===r.memoizedState||(o.flags|=4),typeof w.getSnapshotBeforeUpdate!="function"||H===r.memoizedProps&&Et===r.memoizedState||(o.flags|=1024),o.memoizedProps=m,o.memoizedState=At),w.props=m,w.state=At,w.context=tt,m=Pt):(typeof w.componentDidUpdate!="function"||H===r.memoizedProps&&Et===r.memoizedState||(o.flags|=4),typeof w.getSnapshotBeforeUpdate!="function"||H===r.memoizedProps&&Et===r.memoizedState||(o.flags|=1024),m=!1)}return w=m,Xl(r,o),m=(o.flags&128)!==0,w||m?(w=o.stateNode,h=m&&typeof h.getDerivedStateFromError!="function"?null:w.render(),o.flags|=1,r!==null&&m?(o.child=Fl(o,r.child,null,S),o.child=Fl(o,null,h,S)):di(r,o,h,S),o.memoizedState=w.state,r=o.child):r=Gi(r,o,S),r}function nl(r,o,h,m){return Xu(),o.flags|=256,di(r,o,h,m),o.child}var is={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function as(r){return{baseLanes:r,cachePool:id()}}function Oi(r,o,h){return r=r!==null?r.childLanes&~h:0,o&&(r|=Pr),r}function ac(r,o,h){var m=o.pendingProps,S=!1,w=(o.flags&128)!==0,H;if((H=w)||(H=r!==null&&r.memoizedState===null?!1:(hi.current&2)!==0),H&&(S=!0,o.flags&=-129),H=(o.flags&32)!==0,o.flags&=-33,r===null){if(xn){if(S?$o(o):tl(),xn){var k=ni,tt;if(tt=k){t:{for(tt=k,k=ir;tt.nodeType!==8;){if(!k){k=null;break t}if(tt=Mo(tt.nextSibling),tt===null){k=null;break t}}k=tt}k!==null?(o.memoizedState={dehydrated:k,treeContext:qo!==null?{id:Es,overflow:Ts}:null,retryLane:536870912,hydrationErrors:null},tt=Ba(18,null,null,0),tt.stateNode=k,tt.return=o,o.child=tt,Ma=o,ni=null,tt=!0):tt=!1}tt||Yo(o)}if(k=o.memoizedState,k!==null&&(k=k.dehydrated,k!==null))return oS(k)?o.lanes=32:o.lanes=536870912,null;Cs(o)}return k=m.children,m=m.fallback,S?(tl(),S=o.mode,k=ql({mode:"hidden",children:k},S),m=Xo(m,S,h,null),k.return=o,m.return=o,k.sibling=m,o.child=k,S=o.child,S.memoizedState=as(h),S.childLanes=Oi(r,H,h),o.memoizedState=is,m):($o(o),Ds(o,k))}if(tt=r.memoizedState,tt!==null&&(k=tt.dehydrated,k!==null)){if(w)o.flags&256?($o(o),o.flags&=-257,o=ra(r,o,h)):o.memoizedState!==null?(tl(),o.child=r.child,o.flags|=128,o=null):(tl(),S=m.fallback,k=o.mode,m=ql({mode:"visible",children:m.children},k),S=Xo(S,k,h,null),S.flags|=2,m.return=o,S.return=o,m.sibling=S,o.child=m,Fl(o,r.child,null,h),m=o.child,m.memoizedState=as(h),m.childLanes=Oi(r,H,h),o.memoizedState=is,o=S);else if($o(o),oS(k)){if(H=k.nextSibling&&k.nextSibling.dataset,H)var bt=H.dgst;H=bt,m=Error(n(419)),m.stack="",m.digest=H,Bl({value:m,source:null,stack:null}),o=ra(r,o,h)}else if(Vi||wr(r,o,h,!1),H=(h&r.childLanes)!==0,Vi||H){if(H=Xe,H!==null&&(m=h&-h,m=(m&42)!==0?1:Ee(m),m=(m&(H.suspendedLanes|h))!==0?0:m,m!==0&&m!==tt.retryLane))throw tt.retryLane=m,oo(r,m),Ca(H,r,m),ty;k.data==="$?"||u(),o=ra(r,o,h)}else k.data==="$?"?(o.flags|=192,o.child=r.child,o=null):(r=tt.treeContext,ni=Mo(k.nextSibling),Ma=o,xn=!0,Ar=null,ir=!1,r!==null&&(ia[nr++]=Es,ia[nr++]=Ts,ia[nr++]=qo,Es=r.id,Ts=r.overflow,qo=o),o=Ds(o,m.children),o.flags|=4096);return o}return S?(tl(),S=m.fallback,k=o.mode,tt=r.child,bt=tt.sibling,m=ci(tt,{mode:"hidden",children:m.children}),m.subtreeFlags=tt.subtreeFlags&65011712,bt!==null?S=ci(bt,S):(S=Xo(S,k,h,null),S.flags|=2),S.return=o,m.return=o,m.sibling=S,o.child=m,m=S,S=o.child,k=r.child.memoizedState,k===null?k=as(h):(tt=k.cachePool,tt!==null?(bt=bi._currentValue,tt=tt.parent!==bt?{parent:bt,pool:bt}:tt):tt=id(),k={baseLanes:k.baseLanes|h,cachePool:tt}),S.memoizedState=k,S.childLanes=Oi(r,H,h),o.memoizedState=is,m):($o(o),h=r.child,r=h.sibling,h=ci(h,{mode:"visible",children:m.children}),h.return=o,h.sibling=null,r!==null&&(H=o.deletions,H===null?(o.deletions=[r],o.flags|=16):H.push(r)),o.child=h,o.memoizedState=null,h)}function Ds(r,o){return o=ql({mode:"visible",children:o},r.mode),o.return=r,r.child=o}function ql(r,o){return r=Ba(22,r,null,o),r.lanes=0,r.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null},r}function ra(r,o,h){return Fl(o,r.child,null,h),r=Ds(o,o.pendingProps.children),r.flags|=2,o.memoizedState=null,r}function sa(r,o,h){r.lanes|=o;var m=r.alternate;m!==null&&(m.lanes|=o),Dm(r.return,o,h)}function pn(r,o,h,m,S){var w=r.memoizedState;w===null?r.memoizedState={isBackwards:o,rendering:null,renderingStartTime:0,last:m,tail:h,tailMode:S}:(w.isBackwards=o,w.rendering=null,w.renderingStartTime=0,w.last=m,w.tail=h,w.tailMode=S)}function Nr(r,o,h){var m=o.pendingProps,S=m.revealOrder,w=m.tail;if(di(r,o,m.children,h),m=hi.current,(m&2)!==0)m=m&1|2,o.flags|=128;else{if(r!==null&&(r.flags&128)!==0)t:for(r=o.child;r!==null;){if(r.tag===13)r.memoizedState!==null&&sa(r,h,o);else if(r.tag===19)sa(r,h,o);else if(r.child!==null){r.child.return=r,r=r.child;continue}if(r===o)break t;for(;r.sibling===null;){if(r.return===null||r.return===o)break t;r=r.return}r.sibling.return=r.return,r=r.sibling}m&=1}switch(Dt(hi,m),S){case"forwards":for(h=o.child,S=null;h!==null;)r=h.alternate,r!==null&&vo(r)===null&&(S=h),h=h.sibling;h=S,h===null?(S=o.child,o.child=null):(S=h.sibling,h.sibling=null),pn(o,!1,S,h,w);break;case"backwards":for(h=null,S=o.child,o.child=null;S!==null;){if(r=S.alternate,r!==null&&vo(r)===null){o.child=S;break}r=S.sibling,S.sibling=h,h=S,S=r}pn(o,!0,h,null,w);break;case"together":pn(o,!1,null,null,void 0);break;default:o.memoizedState=null}return o.child}function Gi(r,o,h){if(r!==null&&(o.dependencies=r.dependencies),ls|=o.lanes,(h&o.childLanes)===0)if(r!==null){if(wr(r,o,h,!1),(h&o.childLanes)===0)return null}else return null;if(r!==null&&o.child!==r.child)throw Error(n(153));if(o.child!==null){for(r=o.child,h=ci(r,r.pendingProps),o.child=h,h.return=o;r.sibling!==null;)r=r.sibling,h=h.sibling=ci(r,r.pendingProps),h.return=o;h.sibling=null}return o.child}function Lf(r,o){return(r.lanes&o)!==0?!0:(r=r.dependencies,!!(r!==null&&_f(r)))}function Ur(r,o,h){switch(o.tag){case 3:te(o,o.stateNode.containerInfo),Wo(o,bi,r.memoizedState.cache),Xu();break;case 27:case 5:Te(o);break;case 4:te(o,o.stateNode.containerInfo);break;case 10:Wo(o,o.type,o.memoizedProps.value);break;case 13:var m=o.memoizedState;if(m!==null)return m.dehydrated!==null?($o(o),o.flags|=128,null):(h&o.child.childLanes)!==0?ac(r,o,h):($o(o),r=Gi(r,o,h),r!==null?r.sibling:null);$o(o);break;case 19:var S=(r.flags&128)!==0;if(m=(h&o.childLanes)!==0,m||(wr(r,o,h,!1),m=(h&o.childLanes)!==0),S){if(m)return Nr(r,o,h);o.flags|=128}if(S=o.memoizedState,S!==null&&(S.rendering=null,S.tail=null,S.lastEffect=null),Dt(hi,hi.current),m)break;return null;case 22:case 23:return o.lanes=0,Gl(r,o,h);case 24:Wo(o,bi,r.memoizedState.cache)}return Gi(r,o,h)}function Yl(r,o,h){if(r!==null)if(r.memoizedProps!==o.pendingProps)Vi=!0;else{if(!Lf(r,h)&&(o.flags&128)===0)return Vi=!1,Ur(r,o,h);Vi=(r.flags&131072)!==0}else Vi=!1,xn&&(o.flags&1048576)!==0&&Jh(o,ku,o.index);switch(o.lanes=0,o.tag){case 16:t:{r=o.pendingProps;var m=o.elementType,S=m._init;if(m=S(m._payload),o.type=m,typeof m=="function")mf(m)?(r=Rs(m,r),o.tag=1,o=or(null,o,m,r,h)):(o.tag=0,o=ic(null,o,m,r,h));else{if(m!=null){if(S=m.$$typeof,S===O){o.tag=11,o=Ha(null,o,m,r,h);break t}else if(S===L){o.tag=14,o=Fa(null,o,m,r,h);break t}}throw o=lt(m)||m,Error(n(306,o,""))}}return o;case 0:return ic(r,o,o.type,o.pendingProps,h);case 1:return m=o.type,S=Rs(m,o.pendingProps),or(r,o,m,S,h);case 3:t:{if(te(o,o.stateNode.containerInfo),r===null)throw Error(n(387));m=o.pendingProps;var w=o.memoizedState;S=w.element,He(r,o),Qu(o,m,null,h);var H=o.memoizedState;if(m=H.cache,Wo(o,bi,m),m!==w.cache&&yf(o,[bi],h,!0),Af(),m=H.element,w.isDehydrated)if(w={element:m,isDehydrated:!1,cache:H.cache},o.updateQueue.baseState=w,o.memoizedState=w,o.flags&256){o=nl(r,o,m,h);break t}else if(m!==S){S=na(Error(n(424)),o),Bl(S),o=nl(r,o,m,h);break t}else{switch(r=o.stateNode.containerInfo,r.nodeType){case 9:r=r.body;break;default:r=r.nodeName==="HTML"?r.ownerDocument.body:r}for(ni=Mo(r.firstChild),Ma=o,xn=!0,Ar=null,ir=!0,h=Wv(o,null,m,h),o.child=h;h;)h.flags=h.flags&-3|4096,h=h.sibling}else{if(Xu(),m===S){o=Gi(r,o,h);break t}di(r,o,m,h)}o=o.child}return o;case 26:return Xl(r,o),r===null?(h=LT(o.type,null,o.pendingProps,null))?o.memoizedState=h:xn||(h=o.type,r=o.pendingProps,m=sy(Nt.current).createElement(h),m[xi]=o,m[$n]=r,Ra(m,h,r),Si(m),o.stateNode=m):o.memoizedState=LT(o.type,r.memoizedProps,o.pendingProps,r.memoizedState),null;case 27:return Te(o),r===null&&xn&&(m=o.stateNode=OT(o.type,o.pendingProps,Nt.current),Ma=o,ir=!0,S=ni,pc(o.type)?(lS=S,ni=Mo(m.firstChild)):ni=S),di(r,o,o.pendingProps.children,h),Xl(r,o),r===null&&(o.flags|=4194304),o.child;case 5:return r===null&&xn&&((S=m=ni)&&(m=GO(m,o.type,o.pendingProps,ir),m!==null?(o.stateNode=m,Ma=o,ni=Mo(m.firstChild),ir=!1,S=!0):S=!1),S||Yo(o)),Te(o),S=o.type,w=o.pendingProps,H=r!==null?r.memoizedProps:null,m=w.children,aS(S,w)?m=null:H!==null&&aS(S,H)&&(o.flags|=32),o.memoizedState!==null&&(S=mo(r,o,V1,null,null,h),c0._currentValue=S),Xl(r,o),di(r,o,m,h),o.child;case 6:return r===null&&xn&&((r=h=ni)&&(h=kO(h,o.pendingProps,ir),h!==null?(o.stateNode=h,Ma=o,ni=null,r=!0):r=!1),r||Yo(o)),null;case 13:return ac(r,o,h);case 4:return te(o,o.stateNode.containerInfo),m=o.pendingProps,r===null?o.child=Fl(o,null,m,h):di(r,o,m,h),o.child;case 11:return Ha(r,o,o.type,o.pendingProps,h);case 7:return di(r,o,o.pendingProps,h),o.child;case 8:return di(r,o,o.pendingProps.children,h),o.child;case 12:return di(r,o,o.pendingProps.children,h),o.child;case 10:return m=o.pendingProps,Wo(o,o.type,m.value),di(r,o,m.children,h),o.child;case 9:return S=o.type._context,m=o.pendingProps.children,As(o),S=ri(S),m=m(S),o.flags|=1,di(r,o,m,h),o.child;case 14:return Fa(r,o,o.type,o.pendingProps,h);case 15:return $m(r,o,o.type,o.pendingProps,h);case 19:return Nr(r,o,h);case 31:return m=o.pendingProps,h=o.mode,m={mode:m.mode,children:m.children},r===null?(h=ql(m,h),h.ref=o.ref,o.child=h,h.return=o,o=h):(h=ci(r.child,m),h.ref=o.ref,o.child=h,h.return=o,o=h),o;case 22:return Gl(r,o,h);case 24:return As(o),m=ri(bi),r===null?(S=Zo(),S===null&&(S=Xe,w=ed(),S.pooledCache=w,w.refCount++,w!==null&&(S.pooledCacheLanes|=h),S=w),o.memoizedState={parent:m,cache:S},Lm(o),Wo(o,bi,S)):((r.lanes&h)!==0&&(He(r,o),Qu(o,null,null,h),Af()),S=r.memoizedState,w=o.memoizedState,S.parent!==m?(S={parent:m,cache:m},o.memoizedState=S,o.lanes===0&&(o.memoizedState=o.updateQueue.baseState=S),Wo(o,bi,m)):(m=w.cache,Wo(o,bi,m),m!==S.cache&&yf(o,[bi],h,!0))),di(r,o,o.pendingProps.children,h),o.child;case 29:throw o.pendingProps}throw Error(n(156,o.tag))}function rs(r){r.flags|=4}function zf(r,o){if(o.type!=="stylesheet"||(o.state.loading&4)!==0)r.flags&=-16777217;else if(r.flags|=16777216,!HT(o)){if(o=Ln.current,o!==null&&((Oe&4194048)===Oe?ns!==null:(Oe&62914560)!==Oe&&(Oe&536870912)===0||o!==ns))throw Rr=Tf,Nm;r.flags|=8192}}function yo(r,o){o!==null&&(r.flags|=4),r.flags&16384&&(o=r.tag!==22?Gt():536870912,r.lanes|=o,vn|=o)}function jl(r,o){if(!xn)switch(r.tailMode){case"hidden":o=r.tail;for(var h=null;o!==null;)o.alternate!==null&&(h=o),o=o.sibling;h===null?r.tail=null:h.sibling=null;break;case"collapsed":h=r.tail;for(var m=null;h!==null;)h.alternate!==null&&(m=h),h=h.sibling;m===null?o||r.tail===null?r.tail=null:r.tail.sibling=null:m.sibling=null}}function Fn(r){var o=r.alternate!==null&&r.alternate.child===r.child,h=0,m=0;if(o)for(var S=r.child;S!==null;)h|=S.lanes|S.childLanes,m|=S.subtreeFlags&65011712,m|=S.flags&65011712,S.return=r,S=S.sibling;else for(S=r.child;S!==null;)h|=S.lanes|S.childLanes,m|=S.subtreeFlags,m|=S.flags,S.return=r,S=S.sibling;return r.subtreeFlags|=m,r.childLanes=h,o}function t0(r,o,h){var m=o.pendingProps;switch(wm(o),o.tag){case 31:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return Fn(o),null;case 1:return Fn(o),null;case 3:return h=o.stateNode,m=null,r!==null&&(m=r.memoizedState.cache),o.memoizedState.cache!==m&&(o.flags|=2048),co(bi),se(),h.pendingContext&&(h.context=h.pendingContext,h.pendingContext=null),(r===null||r.child===null)&&($r(o)?rs(o):r===null||r.memoizedState.isDehydrated&&(o.flags&256)===0||(o.flags|=1024,Tn())),Fn(o),null;case 26:return h=o.memoizedState,r===null?(rs(o),h!==null?(Fn(o),zf(o,h)):(Fn(o),o.flags&=-16777217)):h?h!==r.memoizedState?(rs(o),Fn(o),zf(o,h)):(Fn(o),o.flags&=-16777217):(r.memoizedProps!==m&&rs(o),Fn(o),o.flags&=-16777217),null;case 27:an(o),h=Nt.current;var S=o.type;if(r!==null&&o.stateNode!=null)r.memoizedProps!==m&&rs(o);else{if(!m){if(o.stateNode===null)throw Error(n(166));return Fn(o),null}r=Ot.current,$r(o)?hv(o):(r=OT(S,m,h),o.stateNode=r,rs(o))}return Fn(o),null;case 5:if(an(o),h=o.type,r!==null&&o.stateNode!=null)r.memoizedProps!==m&&rs(o);else{if(!m){if(o.stateNode===null)throw Error(n(166));return Fn(o),null}if(r=Ot.current,$r(o))hv(o);else{switch(S=sy(Nt.current),r){case 1:r=S.createElementNS("http://www.w3.org/2000/svg",h);break;case 2:r=S.createElementNS("http://www.w3.org/1998/Math/MathML",h);break;default:switch(h){case"svg":r=S.createElementNS("http://www.w3.org/2000/svg",h);break;case"math":r=S.createElementNS("http://www.w3.org/1998/Math/MathML",h);break;case"script":r=S.createElement("div"),r.innerHTML=" + + + +
+ + diff --git a/frontend/dist/mockImg/artpice1.png b/frontend/dist/mockImg/artpice1.png new file mode 100644 index 0000000000..212049cde2 Binary files /dev/null and b/frontend/dist/mockImg/artpice1.png differ diff --git a/frontend/dist/mockImg/artpice2.png b/frontend/dist/mockImg/artpice2.png new file mode 100644 index 0000000000..5fa697fbc6 Binary files /dev/null and b/frontend/dist/mockImg/artpice2.png differ diff --git a/frontend/dist/mockImg/cells.png b/frontend/dist/mockImg/cells.png new file mode 100644 index 0000000000..25704df32f Binary files /dev/null and b/frontend/dist/mockImg/cells.png differ diff --git a/frontend/dist/mockImg/dots.png b/frontend/dist/mockImg/dots.png new file mode 100644 index 0000000000..a080814212 Binary files /dev/null and b/frontend/dist/mockImg/dots.png differ diff --git a/frontend/index.html b/frontend/index.html index 664410b5b9..aa529ae634 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,13 @@ - Technigo React Vite Boiler Plate + + + + Technigo React Vite Boiler Plate!
diff --git a/frontend/package.json b/frontend/package.json index 7b2747e949..7dce9aa764 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,11 +10,23 @@ "preview": "vite preview" }, "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" + "@react-three/drei": "^10.6.1", + "@react-three/fiber": "^9.3.0", + "@react-three/postprocessing": "^3.0.4", + "axios": "^1.11.0", + "framer-motion": "^12.23.12", + "gsap": "^3.13.0", + "nipplejs": "^0.10.2", + "randomcolor": "^0.6.2", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-router-dom": "^7.8.0", + "react-swipeable": "^7.0.2", + "styled-components": "^6.1.19", + "three": "^0.179.1" }, "devDependencies": { - "@types/react": "^18.2.15", + "@types/react": "^18.3.23", "@types/react-dom": "^18.2.7", "@vitejs/plugin-react": "^4.0.3", "eslint": "^8.45.0", diff --git a/frontend/public/mockImg/artpice1.png b/frontend/public/mockImg/artpice1.png new file mode 100644 index 0000000000..212049cde2 Binary files /dev/null and b/frontend/public/mockImg/artpice1.png differ diff --git a/frontend/public/mockImg/artpice2.png b/frontend/public/mockImg/artpice2.png new file mode 100644 index 0000000000..5fa697fbc6 Binary files /dev/null and b/frontend/public/mockImg/artpice2.png differ diff --git a/frontend/public/mockImg/cells.png b/frontend/public/mockImg/cells.png new file mode 100644 index 0000000000..25704df32f Binary files /dev/null and b/frontend/public/mockImg/cells.png differ diff --git a/frontend/public/mockImg/dots.png b/frontend/public/mockImg/dots.png new file mode 100644 index 0000000000..a080814212 Binary files /dev/null and b/frontend/public/mockImg/dots.png differ diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg deleted file mode 100644 index e7b8dfb1b2..0000000000 --- a/frontend/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0a24275e6e..443459282f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,78 @@ -export const App = () => { +import { Routes, Route } from 'react-router-dom'; +import { useEffect } from 'react'; +import AppLayout from './components/AppLayout'; +import IdeasFetcher from './components/IdeasFetcher'; +import ProtectedRoute from './components/ProtectedRoute'; +import PublicRoute from './components/PublicRoute'; +import useAuthStore from './store/useAuthStore'; +import useUserStore from './store/useUserStore'; +import useAuthInitializer from './hooks/useAuthInitializer'; +import useUserProfileLoader from './hooks/useUserProfileLoader'; +import useLoginPrompt from './hooks/useLoginPrompt'; + +// Profile pages +import ProfilePage from './pages/ProfilePage/ProfilePage'; +import MyIdeaCardEdit from './pages/MyIdeaPage/MyIdeaCardEdit'; +import UserProfilePage from './pages/UserProfilePage/UserProfilePage'; + +// Auth pages +import LoginPage from './pages/Auth/LoginPage'; +import RegisterPage from './pages/Auth/RegisterPage'; + +// Ideas pages +import IdeaPage from './pages/ideas/IdeaPage/IdeaPage'; + +const App = () => { + const initializeAuth = useAuthStore((state) => state.initializeAuth); + const fetchUserProfile = useUserStore((state) => state.fetchUserProfile); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + useAuthInitializer(); + + // (handled by useAuthInitializer) + + useUserProfileLoader(isAuthenticated); + useLoginPrompt(isAuthenticated); return ( <> -

Welcome to Final Project!

+ + + {/* Auth routes - public but redirect if authenticated */} + + <> + + + + + } + /> + + <> + + + + + } + /> + + {/* Main app routes - protected by authentication */} + + + + } + /> + ); }; + +export default App; diff --git a/frontend/src/assets/boiler-plate.svg b/frontend/src/assets/boiler-plate.svg deleted file mode 100644 index c9252833b4..0000000000 --- a/frontend/src/assets/boiler-plate.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/assets/icons/arrow_back.svg b/frontend/src/assets/icons/arrow_back.svg new file mode 100644 index 0000000000..9a58667c2d --- /dev/null +++ b/frontend/src/assets/icons/arrow_back.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/arrow_forward.svg b/frontend/src/assets/icons/arrow_forward.svg new file mode 100644 index 0000000000..a546b872d3 --- /dev/null +++ b/frontend/src/assets/icons/arrow_forward.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/at.svg b/frontend/src/assets/icons/at.svg new file mode 100644 index 0000000000..f751e1ef69 --- /dev/null +++ b/frontend/src/assets/icons/at.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/at_bold.svg b/frontend/src/assets/icons/at_bold.svg new file mode 100644 index 0000000000..6d1a440b10 --- /dev/null +++ b/frontend/src/assets/icons/at_bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/callMade.svg b/frontend/src/assets/icons/callMade.svg new file mode 100644 index 0000000000..1b9d20cf5f --- /dev/null +++ b/frontend/src/assets/icons/callMade.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/close.svg b/frontend/src/assets/icons/close.svg new file mode 100644 index 0000000000..5aebd54e96 --- /dev/null +++ b/frontend/src/assets/icons/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/delete.svg b/frontend/src/assets/icons/delete.svg new file mode 100644 index 0000000000..9ae126c973 --- /dev/null +++ b/frontend/src/assets/icons/delete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/delete_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg b/frontend/src/assets/icons/delete_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg new file mode 100644 index 0000000000..ef53c233a2 --- /dev/null +++ b/frontend/src/assets/icons/delete_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/drag.svg b/frontend/src/assets/icons/drag.svg new file mode 100644 index 0000000000..fd73c728ac --- /dev/null +++ b/frontend/src/assets/icons/drag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/edit.svg b/frontend/src/assets/icons/edit.svg new file mode 100644 index 0000000000..c029b3765d --- /dev/null +++ b/frontend/src/assets/icons/edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/edit_square_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg b/frontend/src/assets/icons/edit_square_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg new file mode 100644 index 0000000000..dfdfca8142 --- /dev/null +++ b/frontend/src/assets/icons/edit_square_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/expand.svg b/frontend/src/assets/icons/expand.svg new file mode 100644 index 0000000000..cd88dd04e0 --- /dev/null +++ b/frontend/src/assets/icons/expand.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/expandSlice.svg b/frontend/src/assets/icons/expandSlice.svg new file mode 100644 index 0000000000..18211dde29 --- /dev/null +++ b/frontend/src/assets/icons/expandSlice.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/headIcon.svg b/frontend/src/assets/icons/headIcon.svg new file mode 100644 index 0000000000..0e2150a21c --- /dev/null +++ b/frontend/src/assets/icons/headIcon.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/icons/heart.svg b/frontend/src/assets/icons/heart.svg new file mode 100644 index 0000000000..36edc9c5a9 --- /dev/null +++ b/frontend/src/assets/icons/heart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/heart_broken.svg b/frontend/src/assets/icons/heart_broken.svg new file mode 100644 index 0000000000..d02ae94e17 --- /dev/null +++ b/frontend/src/assets/icons/heart_broken.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/heart_fill.svg b/frontend/src/assets/icons/heart_fill.svg new file mode 100644 index 0000000000..9e6edbd9ce --- /dev/null +++ b/frontend/src/assets/icons/heart_fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/more_horiz.svg b/frontend/src/assets/icons/more_horiz.svg new file mode 100644 index 0000000000..d53bc2bff8 --- /dev/null +++ b/frontend/src/assets/icons/more_horiz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/more_vert.svg b/frontend/src/assets/icons/more_vert.svg new file mode 100644 index 0000000000..05f703cabf --- /dev/null +++ b/frontend/src/assets/icons/more_vert.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/plus_large.svg b/frontend/src/assets/icons/plus_large.svg new file mode 100644 index 0000000000..8231cf2e19 --- /dev/null +++ b/frontend/src/assets/icons/plus_large.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/plus_small.svg b/frontend/src/assets/icons/plus_small.svg new file mode 100644 index 0000000000..230d7c9f2a --- /dev/null +++ b/frontend/src/assets/icons/plus_small.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons/unfold_less.svg b/frontend/src/assets/icons/unfold_less.svg new file mode 100644 index 0000000000..a25bf18f7c --- /dev/null +++ b/frontend/src/assets/icons/unfold_less.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/img/artpice1.png b/frontend/src/assets/img/artpice1.png new file mode 100644 index 0000000000..212049cde2 Binary files /dev/null and b/frontend/src/assets/img/artpice1.png differ diff --git a/frontend/src/assets/img/artpice2.png b/frontend/src/assets/img/artpice2.png new file mode 100644 index 0000000000..5fa697fbc6 Binary files /dev/null and b/frontend/src/assets/img/artpice2.png differ diff --git a/frontend/src/assets/img/cells.png b/frontend/src/assets/img/cells.png new file mode 100644 index 0000000000..25704df32f Binary files /dev/null and b/frontend/src/assets/img/cells.png differ diff --git a/frontend/src/assets/img/dots.png b/frontend/src/assets/img/dots.png new file mode 100644 index 0000000000..a080814212 Binary files /dev/null and b/frontend/src/assets/img/dots.png differ diff --git a/frontend/src/assets/img/subtle-normal.png b/frontend/src/assets/img/subtle-normal.png new file mode 100644 index 0000000000..c9ede69671 --- /dev/null +++ b/frontend/src/assets/img/subtle-normal.png @@ -0,0 +1,3 @@ +// Simple subtle normal map for surface detail +// Source: https://www.transparenttextures.com/patterns/diamond-upholstery.png +// License: Free for personal/commercial use diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9bb3..0000000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/assets/technigo-logo.svg b/frontend/src/assets/technigo-logo.svg deleted file mode 100644 index 3f0da3e572..0000000000 --- a/frontend/src/assets/technigo-logo.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/components/AppLayout.jsx b/frontend/src/components/AppLayout.jsx new file mode 100644 index 0000000000..49addc3785 --- /dev/null +++ b/frontend/src/components/AppLayout.jsx @@ -0,0 +1,158 @@ +import React, { useEffect, useState } from 'react'; +import { useLocation, useNavigate, Routes, Route } from 'react-router-dom'; +import Scene from '../pages/3DScene/3DScene'; +import NavBar from './NavBar'; +import AddIdeaModal from '../modals/AddIdeaModal'; +import ConnectModal from '../modals/ConnectModal'; +import Header from './Header1'; +import { useIdeasStore } from '../store/useIdeasStore'; +import { useUIStore } from '../store/useUIStore'; +import useAuthStore from '../store/useAuthStore'; + +import IdeaPage from '../pages/ideas/IdeaPage/IdeaPage'; +import ProfilePage from '../pages/ProfilePage/ProfilePage'; +import MyIdeaCardEdit from '../pages/MyIdeaPage/MyIdeaCardEdit'; +import UserProfilePage from '../pages/UserProfilePage/UserProfilePage'; +import ConnectionPage from '../pages/ConnectionPage/ConnectionPage'; + +const AppLayout = () => { + const location = useLocation(); + const navigate = useNavigate(); + + // UI state + const isAddOpen = useUIStore((state) => state.isAddOpen); + const setIsAddOpen = useUIStore((state) => state.setIsAddOpen); + const openAddModal = useUIStore((state) => state.openAddModal); + const openConnect = useUIStore((state) => state.openConnectModal); + + // Ideas data + const ideas = useIdeasStore((state) => state.ideas); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + + // NAGIVATION HANDLERS + const navigateLeft = useUIStore((state) => state.navigateLeft); + const navigateRight = useUIStore((state) => state.navigateRight); + + // Route detection + const isModalActive = location.pathname !== '/'; + const isProfileRoute = location.pathname.startsWith('/profile'); + const isIdeasRoute = location.pathname.startsWith('/ideas'); + const isConnectionsRoute = location.pathname.startsWith('/connections'); + const shouldShowModal = isIdeasRoute || isProfileRoute || isConnectionsRoute; + + // Animation state + const [isModalAnimatingOut, setIsModalAnimatingOut] = useState(false); + const [shouldRenderModal, setShouldRenderModal] = useState(shouldShowModal); + const [currentModalRoute, setCurrentModalRoute] = useState( + shouldShowModal ? location.pathname : null + ); + + // Handle modal animation timing + useEffect(() => { + if (shouldShowModal && !shouldRenderModal) { + // Show modal immediately + setShouldRenderModal(true); + setIsModalAnimatingOut(false); + setCurrentModalRoute(location.pathname); + } else if (!shouldShowModal && shouldRenderModal) { + // Start hide animation + setIsModalAnimatingOut(true); + // Wait for animation to complete before removing from DOM + const timer = setTimeout(() => { + setShouldRenderModal(false); + setIsModalAnimatingOut(false); + setCurrentModalRoute(null); + }, 500); // Match animation duration + return () => clearTimeout(timer); + } else if (shouldShowModal && shouldRenderModal && !isModalAnimatingOut) { + // Update route only when not animating + setCurrentModalRoute(location.pathname); + } + }, [ + shouldShowModal, + shouldRenderModal, + location.pathname, + isModalAnimatingOut, + ]); + + // SIMPLE NAVIGATION HANDLERS + const handleLeft = () => navigateLeft(navigate, ideas); + const handleRight = () => navigateRight(navigate, ideas); + + // Add idea handler + const handleSubmitIdea = (ideaData) => { + useIdeasStore.getState().createIdea(ideaData); + setIsAddOpen(false); + }; + + // Connect modal event listener + useEffect(() => { + const handler = (e) => openConnect(e.detail || {}); + window.addEventListener('openConnectModal', handler); + return () => window.removeEventListener('openConnectModal', handler); + }, [openConnect]); + + return ( +
+
+ {/* HERE IS THE NAVBAR */} + { + if (isAuthenticated) { + openAddModal(); + } else { + navigate('/login'); + } + }} + onLeft={handleLeft} + onRight={handleRight} + hideOnMobile={isProfileRoute} + /> + setIsAddOpen(false)} + onSubmit={handleSubmitIdea} + /> + + +
+
+ +
+ + {shouldRenderModal && ( +
+
+
+ + } /> + } /> + } /> + } /> + } + /> + } /> + } + /> + +
+
+ )} +
+
+ ); +}; + +export default AppLayout; diff --git a/frontend/src/components/Button.jsx b/frontend/src/components/Button.jsx new file mode 100644 index 0000000000..e374b8f0c6 --- /dev/null +++ b/frontend/src/components/Button.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import styled from 'styled-components'; + +const StyledButton = styled.button` + flex: 1; + padding: 16px 20px; + border-radius: 14px; + border: 1px solid #232323; + font-size: 16px; + cursor: pointer; + background: ${(p) => (p.$primary ? '#232323' : '#fff')}; + color: ${(p) => (p.$primary ? '#fff' : '#232323')}; + transition: background 0.2s, color 0.2s; + &:hover { + background: ${(p) => (p.$primary ? '#444' : '#f5f5f5')}; + } +`; + +const Button = ({ children, primary, ...rest }) => ( + + {children} + +); + +export default Button; diff --git a/frontend/src/components/CardActions.jsx b/frontend/src/components/CardActions.jsx new file mode 100644 index 0000000000..d83791f582 --- /dev/null +++ b/frontend/src/components/CardActions.jsx @@ -0,0 +1,18 @@ +import React from 'react'; +import styled from 'styled-components'; + +const Wrap = styled.div` + position: absolute; + top: 10px; + right: 10px; + display: flex; + gap: 8px; +`; + +export default function CardActions({ children, className, style }) { + return ( + + {children} + + ); +} diff --git a/frontend/src/components/ColorIdeaCard.jsx b/frontend/src/components/ColorIdeaCard.jsx new file mode 100644 index 0000000000..73d31db6ff --- /dev/null +++ b/frontend/src/components/ColorIdeaCard.jsx @@ -0,0 +1,71 @@ +import React from 'react'; +import styled from 'styled-components'; + +const CardContent = styled.div` + display: flex; + flex-direction: column; + height: 100%; + margin-top: 0; +`; +const IdeaTitle = styled.h3` + font-size: 20px; + font-weight: 700; + line-height: 1.2; + margin: 0 0 4px; + color: #222; +`; +const IdeaDesc = styled.p` + font-size: 16px; + line-height: 1.35; + margin: 6px 0 14px; + color: #111; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + @media (min-width: 768px) { + -webkit-line-clamp: 3; + line-clamp: 3; + } +`; +const OpenButtonWrap = styled.div` + align-self: flex-end; + margin-top: auto; + opacity: ${(props) => (props.$isVisible ? 1 : 0)}; + transition: opacity 0.6s ease-in-out; +`; +const Row = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 16px; + color: #222; + font-size: 15px; +`; + +export default function ColorIdeaCard({ + idea, + actions, + openButton, + showDate = true, +}) { + return ( + + {actions} + {idea.title} + {idea.description || ''} + {openButton && ( + {openButton} + )} + {showDate && ( + + + {new Date(idea.createdAt || Date.now()).toLocaleDateString()} + + + )} + + ); +} diff --git a/frontend/src/components/Header1.jsx b/frontend/src/components/Header1.jsx new file mode 100644 index 0000000000..2f30836958 --- /dev/null +++ b/frontend/src/components/Header1.jsx @@ -0,0 +1,122 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import HeadIcon from '../assets/icons/headIcon.svg'; + +const HeaderWrapper = styled.header` + display: flex; + justify-content: space-between; + align-items: center; + transition: all 0.3s ease; +`; + +const Logo = styled.h1` + font-size: 32px; + font-weight: 600; + color: #000; + margin: 0; + cursor: pointer; + border-radius: 8px; + + &:hover { + transform: scale(1.05); + } +`; + +const HeaderActions = styled.div` + display: flex; + align-items: center; + gap: 12px; +`; + +const IconButton = styled.button` + padding: 0px; + background: none; + border: none; + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background-color 0.2s ease, transform 0.2s ease; + + &:hover { + background-color: rgba(0, 0, 0, 0.05); + transform: scale(1.1); + } +`; + +const ProfileButton = styled.button` + padding: 0px; + background: none; + border: none; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background-color 0.2s ease; + border-radius: 50%; + + &:hover { + background-color: rgba(0, 0, 0, 0.05); + } +`; + +const LogoIcon = styled.img` + transition: transform 0.2s ease; + + &:hover { + transform: scale(1.1); + } +`; + +const IconSVG = styled.svg` + fill: #000; +`; + +const Header = ({ onThemeToggle }) => { + const location = useLocation(); + const navigate = useNavigate(); + + const isOnProfilePage = location.pathname === '/profile'; + + const handleProfileClick = () => { + if (isOnProfilePage) { + // If on profile page, close modal by going to home + navigate('/'); + } else { + // If not on profile page, go to profile + navigate('/profile'); + } + }; + + return ( + + + OurLogo + + + + + + + + + + + + + ); +}; + +export default Header; diff --git a/frontend/src/components/IconButton.jsx b/frontend/src/components/IconButton.jsx new file mode 100644 index 0000000000..8cf124faa9 --- /dev/null +++ b/frontend/src/components/IconButton.jsx @@ -0,0 +1,49 @@ +import React from 'react'; +import styled from 'styled-components'; + +const Btn = styled.button` + width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: #fff; + box-shadow: 0 2px 8px rgba(0,0,0,0.07); + border-radius: 50%; + cursor: pointer; + padding: 0; + + img { + width: 24px; + height: 24px; + filter: none; + opacity: 0.55; + transition: opacity 120ms ease-in-out; + pointer-events: none; + } + + &:hover { + background: #a80000; + } + &:hover img { + opacity: 1; + filter: brightness(0) invert(1); + } +`; + +export default function IconButton({ iconSrc, alt = '', ariaLabel, title, onClick, className, style, children, ...rest }) { + return ( + + {children ? children : {alt} + + ); +} diff --git a/frontend/src/components/IdeasFetcher.jsx b/frontend/src/components/IdeasFetcher.jsx new file mode 100644 index 0000000000..50cfef266a --- /dev/null +++ b/frontend/src/components/IdeasFetcher.jsx @@ -0,0 +1,42 @@ +import React, { useEffect, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useAuthStore } from '../store/useAuthStore'; +import { useIdeasStore } from '../store/useIdeasStore'; + +const IdeasFetcher = () => { + const location = useLocation(); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const fetchIdeas = useIdeasStore((state) => state.fetchIdeas); + const ideas = useIdeasStore((state) => state.ideas); + const isLoading = useIdeasStore((state) => state.isLoading); + const hasInitialized = useRef(false); + const lastAuthState = useRef(null); + + // Fetch ideas when user becomes authenticated or when on home page + useEffect(() => { + const isHomePage = location.pathname === '/'; + + // Fetch ideas if: + // 1. User is authenticated (for all pages) + // 2. User is on home page (even if not authenticated) + if ( + (isAuthenticated || isHomePage) && + !isLoading && + (!ideas || ideas.length === 0) + ) { + fetchIdeas(); + hasInitialized.current = true; + } + }, [ + isAuthenticated, + isLoading, + fetchIdeas, + location.pathname, + ideas?.length, + ]); + + // This component doesn't render anything + return null; +}; + +export default IdeasFetcher; diff --git a/frontend/src/components/Joystick.jsx b/frontend/src/components/Joystick.jsx new file mode 100644 index 0000000000..8145987c0c --- /dev/null +++ b/frontend/src/components/Joystick.jsx @@ -0,0 +1,52 @@ +import React, { useEffect, useRef } from "react"; +import nipplejs from "nipplejs"; + +const Joystick = ({ onMove }) => { + const joystickRef = useRef(null); + + useEffect(() => { + if (!joystickRef.current) return; + const manager = nipplejs.create({ + zone: joystickRef.current, + mode: "static", + color: "black", + size: 90, + position: { left: "50%", top: "50%" }, + multitouch: false, + restOpacity: 0.8, + }); + + const handleMove = (evt, data) => { + if (onMove && data) onMove(data); + }; + const handleEnd = () => { + if (onMove) onMove({ vector: { x: 0, y: 0 }, force: 0 }); + }; + + manager.on("move", handleMove); + manager.on("end", handleEnd); + + return () => { + manager.off("move", handleMove); + manager.off("end", handleEnd); + manager.destroy(); + }; + }, [onMove]); + + return ( +
+ ); +}; + +export default Joystick; \ No newline at end of file diff --git a/frontend/src/components/NavBar.jsx b/frontend/src/components/NavBar.jsx new file mode 100644 index 0000000000..5602c9b555 --- /dev/null +++ b/frontend/src/components/NavBar.jsx @@ -0,0 +1,86 @@ +import leftArrowIcon from '../assets/icons/arrow_back.svg'; +import rightArrowIcon from '../assets/icons/arrow_forward.svg'; +import plusIcon from '../assets/icons/plus_large.svg'; +import styled from 'styled-components'; + +const NavBarWrapper = styled.div` + position: absolute; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: #232323; + border-radius: 12px; + padding: 8px 8px; + display: flex; + align-items: center; + justify-content: space-between; + /* box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15); */ + z-index: 20; + width: min(420px, 300px); + margin-left: auto; + margin-right: auto; + + /* Hide on mobile if requested */ + @media (max-width: 767px) { + display: ${(p) => (p.$hideOnMobile ? 'none' : 'flex')}; + } + @media (max-width: 600px) { + position: fixed; + left: 0; + right: 0; + bottom: 24px; + transform: none; + width: calc(100vw - 16px); + margin: 0 8px; + border-radius: 18px; + } +`; + +const NavButton = styled.button` + background: none; + border: none; + color: #fff; + font-size: 32px; + cursor: pointer; + outline: none; + + &.add { + width: 48px; + height: 48px; + border-radius: 50%; + background: #fff; + color: #232323; + font-size: 28px; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; + /* box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); */ + } +`; + +const NavBar = ({ onAdd, onLeft, onRight, hideOnMobile = false }) => ( + + + Left + +
+ + Add + +
+ + Right + +
+); + +export default NavBar; diff --git a/frontend/src/components/OpenIdeaButton.jsx b/frontend/src/components/OpenIdeaButton.jsx new file mode 100644 index 0000000000..c5500cafbb --- /dev/null +++ b/frontend/src/components/OpenIdeaButton.jsx @@ -0,0 +1,100 @@ +import React from 'react'; +import styled, { css } from 'styled-components'; +import { Link } from 'react-router-dom'; +import arrowIcon from '../assets/icons/arrow_forward.svg'; +import { useIdeasStore } from '../store/useIdeasStore'; + +const sharedStyles = css` + display: inline-flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + min-height: 40px; + border-radius: 12px; + cursor: pointer; + font-size: 16px; + font-weight: lighter; + text-decoration: none; + letter-spacing: 0.02em; + text-transform: uppercase; + line-height: 1; + transition: background-color 0.2s ease, box-shadow 0.2s ease, + transform 0.2s ease, border-color 0.2s ease, color 0.2s ease; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.16); + + &:hover { + transform: translateY(-1px); + } + &:active { + transform: translateY(0); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.16); + } + + img { + width: 18px; + height: 18px; + pointer-events: none; + } +`; + +const Filled = styled(Link)` + ${sharedStyles}; + background: #232323; + color: #ffffff; + border: none; + + &:hover { + background: #111111; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2); + } + img { + filter: invert(1) brightness(1.4); + } +`; + +const Outlined = styled(Link)` + ${sharedStyles}; + background: #fff; + color: #232323; + border: 1px solid #232323; + box-shadow: none; + + &:hover { + background: #f7f7f7; + } +`; + +export default function OpenIdeaButton({ + ideaId, + to, + title, + variant = 'primary', + className, + style, + children, +}) { + const ideas = useIdeasStore((s) => s.ideas); + const href = to || `/ideas/${ideaId}`; + const aria = title ? `Open idea "${title}"` : 'Open idea'; + const onClick = () => { + const idx = ideas.findIndex((i) => i._id === ideaId); + if (idx >= 0) + window.dispatchEvent( + new CustomEvent('moveCameraToIndex', { detail: idx }) + ); + }; + + const Btn = variant === 'outlined' ? Outlined : Filled; + + return ( + + {children || 'OPEN IDEA'} open + + ); +} diff --git a/frontend/src/components/PageHeader.jsx b/frontend/src/components/PageHeader.jsx new file mode 100644 index 0000000000..2a0706ec8b --- /dev/null +++ b/frontend/src/components/PageHeader.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import styled from 'styled-components'; + +const PageHeaderContainer = styled.div` + width: 100%; + display: flex; + flex-direction: column; + padding: 0 0 16px 0; + border-bottom: 1px solid #eee; +`; + +const PageTitle = styled.h1` + font-size: 24px; + font-weight: 600; + color: #333; +`; + +const UserInfo = styled.div` + margin-top: 16px; + display: flex; + align-items: center; + gap: 8px; +`; + +const LoggedInText = styled.span` + font-size: 14px; + color: #666; +`; + +const Username = styled.span` + font-size: 16px; + font-weight: 600; + color: #333; +`; + +const PageHeader = ({ title, user }) => { + const hasUserInfo = user && (user.fullName || user.firstName); + + return ( + + {title} + {hasUserInfo && ( + + Logged in: + {user.fullName || user.firstName || 'User'} + + )} + + ); +}; + +export default PageHeader; diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx new file mode 100644 index 0000000000..6ce26bdfa4 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuthStore } from '../store/useAuthStore'; + +const ProtectedRoute = ({ children }) => { + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const isLoading = useAuthStore((state) => state.isLoading); + const isInitializing = useAuthStore((state) => state.isInitializing); + const location = useLocation(); + + // Show loading state while checking authentication or initializing + if (isLoading || isInitializing) { + return ( +
+
Loading...
+
+ ); + } + + // If not authenticated, allow access to public app views ('/' and '/ideas...') + if (!isAuthenticated) { + const path = location.pathname || '/'; + const isPublicPath = path === '/' || path === '/ideas' || path.startsWith('/ideas/'); + if (isPublicPath) { + return children; + } + return ; + } + + // Render the protected content if authenticated + return children; +}; + +export default ProtectedRoute; diff --git a/frontend/src/components/PublicRoute.jsx b/frontend/src/components/PublicRoute.jsx new file mode 100644 index 0000000000..eacf6b0cac --- /dev/null +++ b/frontend/src/components/PublicRoute.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuthStore } from '../store/useAuthStore'; + +const PublicRoute = ({ children }) => { + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const isLoading = useAuthStore((state) => state.isLoading); + + // Show loading state while checking authentication + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + // Redirect to home if already authenticated + if (isAuthenticated) { + return ; + } + + // Render the public content if not authenticated + return children; +}; + +export default PublicRoute; diff --git a/frontend/src/components/SectionHeader.jsx b/frontend/src/components/SectionHeader.jsx new file mode 100644 index 0000000000..9ea46ac801 --- /dev/null +++ b/frontend/src/components/SectionHeader.jsx @@ -0,0 +1,57 @@ +// section header for the ProfilePage + +import React from 'react'; +import styled from 'styled-components'; + +const HeaderWrap = styled.div` + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 10px; + + h2 { + font-size: 18px; + font-weight: 600; + } + .count { + color: #6b6b6b; + font-weight: 400; + margin-left: 6px; + font-size: 14px; + } + button.linklike { + color: #232323; + background: transparent; + border: 0; + padding: 0; + font-size: 14px; + cursor: pointer; + text-decoration: none; + } +`; + +export default function SectionHeader({ + title, + count, + isExpanded = false, + onToggle, +}) { + return ( + +

+ {title}{' '} + {typeof count === 'number' && ({count})} +

+ {onToggle && ( + + )} +
+ ); +} diff --git a/frontend/src/components/StackedIdeaCards.jsx b/frontend/src/components/StackedIdeaCards.jsx new file mode 100644 index 0000000000..284bfa5511 --- /dev/null +++ b/frontend/src/components/StackedIdeaCards.jsx @@ -0,0 +1,81 @@ +import React, { useState } from 'react'; +import styled from 'styled-components'; + +const StackWrap = styled.div` + position: relative; +`; + +const StackCard = styled.div` + position: relative; + border-radius: 16px; + padding: 16px; + color: #121212; + border: 1px solid rgba(0, 0, 0, 0.08); + background: ${(p) => p.$bg || '#f2f2f2'}; + display: flex; + flex-direction: column; + cursor: default; + &:hover { + cursor: pointer; + } + &:hover * { + cursor: pointer !important; + } + transform: ${(p) => { + const baseY = typeof p.$offset === 'number' ? p.$offset : 0; + if (p.$unstacked) + return p.$popped ? 'translateY(0) scale(1.02)' : 'translateY(0)'; + return p.$popped + ? `translateY(${baseY}px) scale(1.02)` + : `translateY(${baseY}px)`; + }}; + box-shadow: ${(p) => + p.$popped + ? '0 18px 36px rgba(0,0,0,0.20), 0 8px 16px rgba(0,0,0,0.12)' + : p.$unstacked + ? '0 4px 12px rgba(0,0,0,0.06)' + : p.$top + ? '0 8px 18px rgba(0,0,0,0.08)' + : 'none'}; + z-index: ${(p) => (p.$popped ? 1000 : p.$z || 1)}; + overflow: hidden; + margin-top: ${(p) => (p.$isFirst ? 0 : p.$unstacked ? 12 : -120)}px; + + transition: transform 260ms cubic-bezier(0.2, 0.8, 0.2, 1), + margin-top 260ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms ease; +`; + +export default function StackedIdeaCards({ + ideas, + renderActions, + renderContent, + unstacked = false, +}) { + const [poppedIdx, setPoppedIdx] = useState(null); + + return ( + + {ideas.map((idea, idx) => { + const isLast = idx === ideas.length - 1; + const isFirst = idx === 0; + const popped = !unstacked && poppedIdx === idx; + return ( + setPoppedIdx((cur) => (cur === idx ? null : idx))} + > + {renderActions && renderActions(idea, idx)} + {renderContent && renderContent(idea, idx)} + + ); + })} + + ); +} diff --git a/frontend/src/components/SubSection.jsx b/frontend/src/components/SubSection.jsx new file mode 100644 index 0000000000..485679290b --- /dev/null +++ b/frontend/src/components/SubSection.jsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import styled from 'styled-components'; + +const Section = styled.section` + display: flex; + flex-direction: column; + gap: 16px; +`; + +const ConnectionsList = styled.div` + display: flex; + flex-direction: column; + gap: 16px; +`; + +const Item = styled.div` + display: flex; + align-items: center; + gap: 16px; +`; + +const Avatar = styled.div` + min-width: 62px; + min-height: 62px; + flex-shrink: 0; + border-radius: 8px; + background: ${(p) => p.bg || '#ddd'}; +`; + +const Title = styled.div` + font-weight: 600; +`; + +const Info = styled.div` + color: #6b6b6b; + font-size: 14px; +`; + +const Message = styled.div` + color: #3d3d3d; + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 420px; +`; + +const EmptyMessage = styled.div` + text-align: center; + color: #666; + padding: 20px; +`; + +const SubSection = ({ + title, + connections = [], + onItemClick, + getItemTitle, + getItemInfo, + getItemMessage, + emptyMessage, +}) => { + const navigate = useNavigate(); + + const handleItemClick = (connection, index) => { + if (onItemClick) { + onItemClick(connection, index); + } + }; + + return ( +
+

+ {title} +

+ + {connections.length > 0 ? ( + connections.map((connection, i) => ( + handleItemClick(connection, i)} + > + +
+ {getItemTitle(connection)} + {getItemInfo(connection)} + {getItemMessage(connection)} +
+
+ )) + ) : ( + {emptyMessage} + )} +
+
+ ); +}; + +export default SubSection; diff --git a/frontend/src/components/TopBar.jsx b/frontend/src/components/TopBar.jsx new file mode 100644 index 0000000000..567a8d4a00 --- /dev/null +++ b/frontend/src/components/TopBar.jsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import styled from 'styled-components'; +import arrowBackIcon from '../assets/icons/arrow_back.svg'; + +const TopBarContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 0 16px 0; + border-bottom: 1px solid #eee; + background: #fff; +`; + +const BackButton = styled.button` + display: flex; + align-items: center; + gap: 8px; + background: none; + border: none; + padding: 0; + cursor: pointer; + font-size: 18px; + font-weight: 400; + color: #333; + + &:hover { + opacity: 0.8; + } + + img { + width: 16px; + height: 16px; + } +`; + +const ActionButton = styled.button` + background: none; + border: none; + padding: 0; + cursor: pointer; + font-size: 18px; + font-weight: 400; + color: #333; + + &:hover { + opacity: 0.8; + } +`; + +const TopBar = ({ + title, + onBack, + backLabel = 'Back', + actionLabel, + onAction, + showBackButton = true, +}) => { + const navigate = useNavigate(); + + const handleBack = () => { + if (onBack) { + onBack(); + } else { + navigate(-1); + } + }; + + return ( + + {showBackButton && ( + + {backLabel} +

{title}

+
+ )} + + {actionLabel && onAction && ( + {actionLabel} + )} +
+ ); +}; + +export default TopBar; diff --git a/frontend/src/components/UnstackToggleButton.jsx b/frontend/src/components/UnstackToggleButton.jsx new file mode 100644 index 0000000000..b040ff4182 --- /dev/null +++ b/frontend/src/components/UnstackToggleButton.jsx @@ -0,0 +1,23 @@ +import React from 'react'; + +export default function UnstackToggleButton({ unstacked, onClick, style }) { + return ( + + ); +} diff --git a/frontend/src/hooks/useAuthInitializer.js b/frontend/src/hooks/useAuthInitializer.js new file mode 100644 index 0000000000..ea80ebce3d --- /dev/null +++ b/frontend/src/hooks/useAuthInitializer.js @@ -0,0 +1,13 @@ +import { useEffect } from 'react'; +import useAuthStore from '../store/useAuthStore'; + +export default function useAuthInitializer() { + const initializeAuth = useAuthStore((state) => state.initializeAuth); + + useEffect(() => { + initializeAuth(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +} + + diff --git a/frontend/src/hooks/useLoginPrompt.js b/frontend/src/hooks/useLoginPrompt.js new file mode 100644 index 0000000000..35ffc38676 --- /dev/null +++ b/frontend/src/hooks/useLoginPrompt.js @@ -0,0 +1,22 @@ +import { useEffect } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +export default function useLoginPrompt(isAuthenticated) { + const location = useLocation(); + const navigate = useNavigate(); + + useEffect(() => { + if (isAuthenticated) return; + + const path = location.pathname || '/'; + + // Allow users to see the home page without being logged in + if (path === '/') return; + + // Don't redirect if already on auth pages + if (path === '/login' || path === '/register') return; + + // Redirect to login immediately for any other interaction + navigate('/login'); + }, [isAuthenticated, location.pathname, navigate]); +} diff --git a/frontend/src/hooks/useSceneNavigation.js b/frontend/src/hooks/useSceneNavigation.js new file mode 100644 index 0000000000..7947b60a6c --- /dev/null +++ b/frontend/src/hooks/useSceneNavigation.js @@ -0,0 +1,97 @@ +import { useEffect } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useUIStore } from '../store/useUIStore'; +import { useIdeasStore } from '../store/useIdeasStore'; + +// Custom hook for keyboard and camera navigation +export function useSceneNavigation({ + ideas, + selectedIndex, + setSelectedIndex, + handleOrbClick, + sphereRadius, +}) { + const location = useLocation(); + const navigate = useNavigate(); + + // Get navigation handlers from UI store + const navigateLeft = useUIStore((state) => state.navigateLeft); + const navigateRight = useUIStore((state) => state.navigateRight); + const navigateLeftSimple = useUIStore((state) => state.navigateLeftSimple); + const navigateRightSimple = useUIStore((state) => state.navigateRightSimple); + + // Check if we're on the ideas route + const isIdeasRoute = location.pathname.startsWith('/ideas'); + + // Keyboard navigation for left/right arrows + useEffect(() => { + const isTypingIntoField = () => { + if (typeof document === 'undefined') return false; + const el = document.activeElement; + if (!el) return false; + const tag = el.tagName; + return ( + tag === 'INPUT' || + tag === 'TEXTAREA' || + el.isContentEditable === true || + (typeof el.getAttribute === 'function' && + el.getAttribute('role') === 'textbox') + ); + }; + + // HANDLE KEYDOWN + const handleKeyDown = (e) => { + if (isTypingIntoField()) return; + + if (e.key === 'ArrowLeft') { + if (isIdeasRoute) { + // On ideas route, use complete navigation (camera + page change) + navigateLeft(navigate, ideas); + } else { + // On other routes, use simple navigation (just selection change) + navigateLeftSimple(); + } + } else if (e.key === 'ArrowRight') { + if (isIdeasRoute) { + // On ideas route, use complete navigation (camera + page change) + navigateRight(navigate, ideas); + } else { + // On other routes, use simple navigation (just selection change) + navigateRightSimple(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [ + isIdeasRoute, + navigateLeft, + navigateRight, + navigateLeftSimple, + navigateRightSimple, + navigate, + ideas, + ]); + + // Listen for camera move events from NavBar + useEffect(() => { + const handler = (e) => { + const idx = e.detail; + if (ideas.length > 0 && typeof idx === 'number') { + const offset = 2 / ideas.length; + const increment = Math.PI * (3 - Math.sqrt(5)); + const y = idx * offset - 1 + offset / 2; + const r = Math.sqrt(1 - y * y); + const phi = idx * increment; + const x = Math.cos(phi) * r; + const z = Math.sin(phi) * r; + handleOrbClick([x * sphereRadius, y * sphereRadius, z * sphereRadius]); + } + }; + window.addEventListener('moveCameraToIndex', handler); + return () => window.removeEventListener('moveCameraToIndex', handler); + }, [ideas.length, handleOrbClick, sphereRadius]); +} diff --git a/frontend/src/hooks/useUserProfileLoader.js b/frontend/src/hooks/useUserProfileLoader.js new file mode 100644 index 0000000000..ab2c5663e4 --- /dev/null +++ b/frontend/src/hooks/useUserProfileLoader.js @@ -0,0 +1,15 @@ +import { useEffect } from 'react'; +import useUserStore from '../store/useUserStore'; + +export default function useUserProfileLoader(isAuthenticated) { + const fetchUserProfile = useUserStore((state) => state.fetchUserProfile); + + useEffect(() => { + if (isAuthenticated) { + fetchUserProfile(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAuthenticated]); +} + + diff --git a/frontend/src/index.css b/frontend/src/index.css index e69de29bb2..7c37b09e18 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -0,0 +1,78 @@ +html, +body, +#root { + isolation: isolate; + width: 100vw; + height: 100vh; + margin: 0; + padding: 0; + overflow: hidden; +} + +/* ========================================================================== + CSS Reset & Base Styles + ========================================================================== */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +* { + margin: 0; +} + +html { + scroll-behavior: smooth; +} + +html, +body { + font-family: 'Questrial', sans-serif; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + overflow-x: clip; +} + +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; +} + +input, +button, +textarea, +select { + font: inherit; + font-family: 'Questrial', sans-serif; + padding: 0px; +} + +p, +h1, +h2, +h3, +h4, +h5, +h6 { + overflow-wrap: break-word; + padding: 0px; +} + +p { + text-wrap: pretty; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + text-wrap: balance; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 51294f3998..6330f02f9d 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,10 +1,14 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; -import { App } from "./App.jsx"; -import "./index.css"; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter as Router } from 'react-router-dom'; +import App from './App.jsx'; +import './index.css'; +import './styles/layout.css'; -ReactDOM.createRoot(document.getElementById("root")).render( +ReactDOM.createRoot(document.getElementById('root')).render( - + + + ); diff --git a/frontend/src/modals/AddIdeaModal.jsx b/frontend/src/modals/AddIdeaModal.jsx new file mode 100644 index 0000000000..146bfcabd9 --- /dev/null +++ b/frontend/src/modals/AddIdeaModal.jsx @@ -0,0 +1,321 @@ +import React, { useState } from 'react'; +import styled from 'styled-components'; +import Button from '../components/Button'; +import { useIdeasStore } from '../store/useIdeasStore'; +import { useUIStore } from '../store/useUIStore'; + +const Overlay = styled.div` + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + display: ${(p) => (p.open ? 'block' : 'none')}; + z-index: 30; +`; + +const Sheet = styled.div` + position: fixed; + left: 50%; + bottom: 0; + transform: translateX(-50%); + width: min(680px, 100%); + background: #fff; + border-top-left-radius: 24px; + border-top-right-radius: 24px; + box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.15); + padding: 24px 20px 16px; + z-index: 5001; +`; + +const Title = styled.h2` + margin: 0 0 12px 0; + font-weight: 600; +`; + +const Label = styled.label` + display: block; + font-size: 14px; + color: #333; + margin: 14px 0 6px; +`; + +const FileList = styled.div` + margin-top: 8px; + padding: 8px; + background: #f5f5f5; + border-radius: 8px; + max-height: 100px; + overflow-y: auto; +`; + +const FileItem = styled.div` + font-size: 12px; + color: #666; + margin: 2px 0; + display: flex; + justify-content: space-between; + align-items: center; +`; + +const RemoveFileButton = styled.button` + background: #ff4444; + color: white; + border: none; + border-radius: 4px; + padding: 2px 6px; + font-size: 10px; + cursor: pointer; + + &:hover { + background: #cc0000; + } +`; + +const Input = styled.input` + width: 100%; + padding: 14px 16px; + border-radius: 12px; + border: 1px solid #ddd; + font-size: 16px; + outline: none; +`; + +const TextArea = styled.textarea` + width: 100%; + min-height: 120px; + padding: 14px 16px; + border-radius: 12px; + border: 1px solid #ddd; + font-size: 16px; + outline: none; + resize: vertical; +`; + +const Row = styled.div` + display: flex; + gap: 16px; + margin-top: 18px; +`; + +const ErrorMessage = styled.div` + color: #d32f2f; + font-size: 12px; + margin-top: 4px; +`; + +const SuccessMessage = styled.div` + color: #28a745; + font-size: 14px; + margin-top: 16px; + padding: 12px; + background-color: #d4edda; + border: 1px solid #c3e6cb; + border-radius: 8px; + text-align: center; + font-weight: 500; +`; + +const AddIdeaSheet = ({ isOpen, onClose }) => { + const [title, setTitle] = useState(''); + const [desc, setDesc] = useState(''); + const [files, setFiles] = useState([]); + const [titleError, setTitleError] = useState(''); + const [descError, setDescError] = useState(''); + const [isSuccess, setIsSuccess] = useState(false); + + // Get store functions + const createIdea = useIdeasStore((state) => state.createIdea); + const isLoading = useIdeasStore((state) => state.isLoading); + const error = useIdeasStore((state) => state.error); + const clearError = useIdeasStore((state) => state.clearError); + const setIsAddOpen = useUIStore((state) => state.setIsAddOpen); + + const handleFiles = (e) => { + const newFiles = Array.from(e.target.files || []); + setFiles((prevFiles) => [...prevFiles, ...newFiles]); + }; + + const removeFile = (index) => { + setFiles(files.filter((_, i) => i !== index)); + }; + + const validateForm = () => { + let isValid = true; + + // Clear previous errors + setTitleError(''); + setDescError(''); + clearError(); + + // Validate title + if (!title.trim()) { + setTitleError('Title is required'); + isValid = false; + } else if (title.trim().length < 3) { + setTitleError('Title must be at least 3 characters'); + isValid = false; + } else if (title.trim().length > 100) { + setTitleError('Title must be less than 100 characters'); + isValid = false; + } + + // Validate description + if (!desc.trim()) { + setDescError('Description is required'); + isValid = false; + } else if (desc.trim().length < 10) { + setDescError('Description must be at least 10 characters'); + isValid = false; + } else if (desc.trim().length > 2000) { + setDescError('Description must be less than 2000 characters'); + isValid = false; + } + + return isValid; + }; + + const handlePost = async (e) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + try { + const result = await createIdea({ + title: title.trim(), + description: desc.trim(), + files, + }); + + if (result.success) { + setIsSuccess(true); + // Reset form + setTitle(''); + setDesc(''); + setFiles([]); + + // Close modal after 2 seconds + setTimeout(() => { + setIsAddOpen(false); + setIsSuccess(false); + }, 2000); + } + } catch (error) { + console.error('Failed to create idea:', error); + } + }; + + const handleClose = () => { + if (!isLoading) { + setTitle(''); + setDesc(''); + setFiles([]); + setTitleError(''); + setDescError(''); + clearError(); + setIsSuccess(false); + setIsAddOpen(false); + } + }; + + const handleTitleChange = (e) => { + setTitle(e.target.value); + setTitleError(''); + clearError(); + setIsSuccess(false); + }; + + const handleDescChange = (e) => { + setDesc(e.target.value); + setDescError(''); + clearError(); + setIsSuccess(false); + }; + + return ( + <> + + {isOpen && ( + e.stopPropagation()} + > +
+ Adding idea + + + + {titleError && {titleError}} + + +