diff --git a/backend/index.js b/backend/index.js index 35d0ab2..1c00e80 100644 --- a/backend/index.js +++ b/backend/index.js @@ -106,9 +106,10 @@ app.use( app.use(cors(corsOptions)); +const isProduction = process.env.NODE_ENV === 'production'; const limiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 100, // Limit each IP to 100 requests per windowMs + windowMs: isProduction ? 15 * 60 * 1000 : 60 * 1000, + max: isProduction ? 100 : 600, standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers message: { diff --git a/backend/middleware/authMiddleware.js b/backend/middleware/authMiddleware.js index 9e98f67..b55bbd2 100644 --- a/backend/middleware/authMiddleware.js +++ b/backend/middleware/authMiddleware.js @@ -1,32 +1,17 @@ -const admin = require('firebase-admin'); - +const { admin, getFirestoreAdmin } = require('../services/firebaseAdmin'); if (!admin.apps.length) { - try { - - if (process.env.GCP_SERVICE_ACCOUNT_KEY) { - - let serviceAccount = process.env.GCP_SERVICE_ACCOUNT_KEY; - if (typeof serviceAccount === 'string') { - try { - serviceAccount = JSON.parse(serviceAccount); - } catch (e) { - console.error("Failed to parse GCP_SERVICE_ACCOUNT_KEY JSON", e); - } - } + // Prefer service-account init from shared helper. + getFirestoreAdmin(); - console.log("Initializing Firebase Admin with project_id:", serviceAccount.project_id); - - admin.initializeApp({ - credential: admin.credential.cert(serviceAccount) - }); - } else { - - console.log("Initializing Firebase Admin with default credentials"); + // Fallback for environments that only provide ADC. + if (!admin.apps.length) { + try { admin.initializeApp(); + console.log('Initializing Firebase Admin with default credentials'); + } catch (error) { + console.warn('Firebase Admin failed to initialize:', error.message); } - } catch (error) { - console.warn("Firebase Admin failed to initialize:", error.message); } } diff --git a/backend/routes/teamRoutes.js b/backend/routes/teamRoutes.js index 7f7bb8f..0ddb15f 100644 --- a/backend/routes/teamRoutes.js +++ b/backend/routes/teamRoutes.js @@ -5,6 +5,21 @@ const User = require('../models/User'); const Team = require('../models/Team'); const { normalizeDoc, normalizeDocs } = require('../utils/normalize'); const { paginateArray, setPaginationHeaders } = require('../utils/pagination'); +const { + upsertTeamSnapshot, + addMemberToTeam, + removeMemberFromTeam, + transferTeamOwnership, + deleteTeamSnapshot, +} = require('../services/teamFirebaseSync'); + +const runSync = async (label, fn) => { + try { + await fn(); + } catch (error) { + console.error(`[TeamSync] ${label} failed:`, error?.message || error); + } +}; const generateInviteCode = async () => { @@ -73,6 +88,7 @@ router.post('/create', verifyToken, async (req, res) => { // Add team to user's memberships const memberships = [...(user.teamMemberships || []), teamObj.id]; await User.updateOne({ uid }, { $set: { teamMemberships: memberships } }); + await runSync('create-team', () => upsertTeamSnapshot(teamObj)); if (initialInvites && Array.isArray(initialInvites) && initialInvites.length > 0) { const { sendZYNCEmail } = require('../services/mailer'); @@ -131,6 +147,8 @@ router.post('/join', verifyToken, async (req, res) => { const teamId = team._id.toString(); const memberships = [...(user.teamMemberships || []), teamId]; await User.updateOne({ uid }, { $set: { teamMemberships: memberships } }); + await runSync('join-team-add-member', () => addMemberToTeam(teamId, uid)); + await runSync('join-team-upsert', () => upsertTeamSnapshot(updatedTeam)); res.status(200).json(normalizeDoc(updatedTeam)); @@ -164,6 +182,7 @@ router.delete('/:teamId', verifyToken, async (req, res) => { } await Team.findByIdAndDelete(teamId); + await runSync('delete-team', () => deleteTeamSnapshot(teamId, team.members, team.ownerId)); res.status(200).json({ message: 'Team deleted successfully' }); } catch (error) { @@ -201,6 +220,7 @@ router.delete('/:teamId/members/:memberUid', verifyToken, async (req, res) => { { $set: { teamMemberships: (member.teamMemberships || []).filter(id => id !== teamId) } } ); } + await runSync('remove-member', () => removeMemberFromTeam(teamId, memberUid)); res.status(200).json({ message: 'Member removed successfully' }); } catch (error) { @@ -275,6 +295,7 @@ router.post('/:teamId/leave', verifyToken, async (req, res) => { { $set: { teamMemberships: (user.teamMemberships || []).filter(id => id !== teamId) } } ); } + await runSync('leave-team', () => removeMemberFromTeam(teamId, uid)); res.status(200).json({ message: 'Left team successfully' }); } catch (error) { @@ -339,6 +360,7 @@ router.patch('/:teamId/transfer-ownership', verifyToken, async (req, res) => { } await Team.findByIdAndUpdate(teamId, { $set: { ownerId: newOwnerId } }); + await runSync('transfer-ownership', () => transferTeamOwnership(teamId, uid, newOwnerId)); res.status(200).json({ message: 'Ownership transferred successfully' }); } catch (error) { @@ -373,6 +395,7 @@ router.patch('/:teamId/name', verifyToken, async (req, res) => { { $set: { name: nextName } }, { returnDocument: 'after', lean: true } ); + await runSync('rename-team', () => upsertTeamSnapshot(updated)); res.status(200).json(normalizeDoc(updated)); } catch (error) { diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js index 23c6304..2bd3138 100644 --- a/backend/routes/userRoutes.js +++ b/backend/routes/userRoutes.js @@ -129,13 +129,19 @@ router.post('/sync', verifyToken, async (req, res) => { } try { + const existingUser = await User.findOne({ uid }).lean(); + const safeEmail = email || existingUser?.email; + if (!safeEmail) { + return res.status(400).json({ message: 'Email is required to sync user profile' }); + } + let finalDisplayName = displayName; - if (!finalDisplayName && email) { - finalDisplayName = email.split('@')[0]; + if (!finalDisplayName && safeEmail) { + finalDisplayName = safeEmail.split('@')[0]; } const updateData = { - email, + email: safeEmail, status: 'online', lastSeen: new Date() }; @@ -146,7 +152,7 @@ router.post('/sync', verifyToken, async (req, res) => { if (lastName) updateData.lastName = lastName; if (timezone) updateData.timezone = timezone; - const result = await User.findOneAndUpdate( + const user = await User.findOneAndUpdate( { uid }, { $set: updateData, @@ -165,23 +171,20 @@ router.post('/sync', verifyToken, async (req, res) => { }, { upsert: true, - returnDocument: 'after', + new: true, lean: true, - includeResultMetadata: true, - rawResult: true, setDefaultsOnInsert: true } ); - const user = result?.value || result; - const isNewUserInsert = wasUserInsertedFromUpsertResult(result); + const isNewUserInsert = !existingUser; if (isNewUserInsert) { console.log(`[SYNC] Sending welcome notifications for newly inserted user: ${uid} (${email})`); // Fire-and-forget: don't block response on external notifications dispatchNewUserNotifications({ displayName: finalDisplayName, - email, + email: safeEmail, uid }); } diff --git a/backend/services/firebaseAdmin.js b/backend/services/firebaseAdmin.js new file mode 100644 index 0000000..2500956 --- /dev/null +++ b/backend/services/firebaseAdmin.js @@ -0,0 +1,52 @@ +const admin = require('firebase-admin'); + +let firestoreInstance = null; +let attemptedInit = false; + +const parseServiceAccount = () => { + const raw = process.env.GCP_SERVICE_ACCOUNT_KEY; + if (!raw) return null; + + let normalized = raw.trim(); + if ( + (normalized.startsWith("'") && normalized.endsWith("'")) || + (normalized.startsWith('"') && normalized.endsWith('"')) + ) { + normalized = normalized.slice(1, -1); + } + + const parsed = JSON.parse(normalized); + if (parsed?.private_key && typeof parsed.private_key === 'string') { + parsed.private_key = parsed.private_key.replace(/\\n/g, '\n'); + } + return parsed; +}; + +const getFirestoreAdmin = () => { + if (firestoreInstance) return firestoreInstance; + if (attemptedInit) return null; + + attemptedInit = true; + try { + const serviceAccount = parseServiceAccount(); + if (!serviceAccount) { + console.warn('[FirebaseAdmin] GCP_SERVICE_ACCOUNT_KEY not set; Firestore sync disabled.'); + return null; + } + + if (!admin.apps.length) { + admin.initializeApp({ + credential: admin.credential.cert(serviceAccount), + projectId: serviceAccount.project_id || process.env.VITE_FIREBASE_PROJECT_ID, + }); + } + + firestoreInstance = admin.firestore(); + return firestoreInstance; + } catch (error) { + console.error('[FirebaseAdmin] Failed to initialize firebase-admin:', error.message); + return null; + } +}; + +module.exports = { admin, getFirestoreAdmin }; diff --git a/backend/services/teamFirebaseSync.js b/backend/services/teamFirebaseSync.js new file mode 100644 index 0000000..ff1f6b4 --- /dev/null +++ b/backend/services/teamFirebaseSync.js @@ -0,0 +1,189 @@ +const { admin, getFirestoreAdmin } = require('./firebaseAdmin'); + +const normalizeUid = (value) => { + if (!value) return ''; + if (typeof value === 'string') return value; + if (typeof value === 'object') return String(value.uid || value.id || value._id || ''); + return String(value); +}; + +const extractOwnerUid = (team) => normalizeUid( + team?.ownerId || + team?.ownerUid || + team?.leaderId || + team?.createdBy || + team?.createdByUid || + team?.owner?.uid || + team?.owner?.id || + team?.owner?._id +); + +const extractTeamId = (teamOrId) => { + if (!teamOrId) return ''; + if (typeof teamOrId === 'string') return teamOrId; + return String(teamOrId.id || teamOrId._id || teamOrId.teamId || ''); +}; + +const toIsoOrNow = (value) => { + if (!value) return new Date().toISOString(); + if (value instanceof Date) return value.toISOString(); + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString(); +}; + +const safeArray = (value) => (Array.isArray(value) ? value : []); + +const upsertTeamSnapshot = async (team) => { + const db = getFirestoreAdmin(); + if (!db || !team) return; + + const teamId = extractTeamId(team); + if (!teamId) return; + + const ownerId = extractOwnerUid(team); + const memberIds = safeArray(team.members).map(normalizeUid).filter(Boolean); + const members = Array.from(new Set([...memberIds, ownerId].filter(Boolean))); + const now = new Date().toISOString(); + + const payload = { + name: team.name || 'Team', + ownerId, + leaderId: ownerId, + members, + inviteCode: team.inviteCode || '', + logoId: team.logoId || 'rocket', + type: team.type || 'Other', + createdAt: toIsoOrNow(team.createdAt), + updatedAt: now, + syncedAt: now, + }; + + await db.collection('teams').doc(teamId).set(payload, { merge: true }); + + if (ownerId) { + await db.collection('users').doc(ownerId).set({ + uid: ownerId, + ownedTeamIds: admin.firestore.FieldValue.arrayUnion(teamId), + teamMemberships: admin.firestore.FieldValue.arrayUnion(teamId), + updatedAt: now, + }, { merge: true }); + } + + for (const memberId of members) { + await db.collection('users').doc(memberId).set({ + uid: memberId, + teamMemberships: admin.firestore.FieldValue.arrayUnion(teamId), + updatedAt: now, + }, { merge: true }); + } +}; + +const addMemberToTeam = async (teamIdOrObj, memberUid) => { + const db = getFirestoreAdmin(); + if (!db) return; + const teamId = extractTeamId(teamIdOrObj); + const uid = normalizeUid(memberUid); + if (!teamId || !uid) return; + + const now = new Date().toISOString(); + await db.collection('teams').doc(teamId).set({ + members: admin.firestore.FieldValue.arrayUnion(uid), + updatedAt: now, + syncedAt: now, + }, { merge: true }); + + await db.collection('users').doc(uid).set({ + uid, + teamMemberships: admin.firestore.FieldValue.arrayUnion(teamId), + updatedAt: now, + }, { merge: true }); +}; + +const removeMemberFromTeam = async (teamIdOrObj, memberUid) => { + const db = getFirestoreAdmin(); + if (!db) return; + const teamId = extractTeamId(teamIdOrObj); + const uid = normalizeUid(memberUid); + if (!teamId || !uid) return; + + const now = new Date().toISOString(); + await db.collection('teams').doc(teamId).set({ + members: admin.firestore.FieldValue.arrayRemove(uid), + updatedAt: now, + syncedAt: now, + }, { merge: true }); + + await db.collection('users').doc(uid).set({ + uid, + teamMemberships: admin.firestore.FieldValue.arrayRemove(teamId), + ownedTeamIds: admin.firestore.FieldValue.arrayRemove(teamId), + updatedAt: now, + }, { merge: true }); +}; + +const transferTeamOwnership = async (teamIdOrObj, previousOwnerUid, nextOwnerUid) => { + const db = getFirestoreAdmin(); + if (!db) return; + const teamId = extractTeamId(teamIdOrObj); + const prevOwner = normalizeUid(previousOwnerUid); + const newOwner = normalizeUid(nextOwnerUid); + if (!teamId || !newOwner) return; + + const now = new Date().toISOString(); + await db.collection('teams').doc(teamId).set({ + ownerId: newOwner, + leaderId: newOwner, + members: admin.firestore.FieldValue.arrayUnion(newOwner), + updatedAt: now, + syncedAt: now, + }, { merge: true }); + + if (prevOwner) { + await db.collection('users').doc(prevOwner).set({ + uid: prevOwner, + ownedTeamIds: admin.firestore.FieldValue.arrayRemove(teamId), + updatedAt: now, + }, { merge: true }); + } + + await db.collection('users').doc(newOwner).set({ + uid: newOwner, + ownedTeamIds: admin.firestore.FieldValue.arrayUnion(teamId), + teamMemberships: admin.firestore.FieldValue.arrayUnion(teamId), + updatedAt: now, + }, { merge: true }); +}; + +const deleteTeamSnapshot = async (teamIdOrObj, memberUids = [], ownerUid) => { + const db = getFirestoreAdmin(); + if (!db) return; + const teamId = extractTeamId(teamIdOrObj); + if (!teamId) return; + + const allMembers = Array.from( + new Set([ + ...safeArray(memberUids).map(normalizeUid).filter(Boolean), + normalizeUid(ownerUid), + ].filter(Boolean)) + ); + + const now = new Date().toISOString(); + await db.collection('teams').doc(teamId).delete(); + + for (const uid of allMembers) { + await db.collection('users').doc(uid).set({ + uid, + teamMemberships: admin.firestore.FieldValue.arrayRemove(teamId), + ownedTeamIds: admin.firestore.FieldValue.arrayRemove(teamId), + updatedAt: now, + }, { merge: true }); + } +}; + +module.exports = { + upsertTeamSnapshot, + addMemberToTeam, + removeMemberFromTeam, + transferTeamOwnership, + deleteTeamSnapshot, +}; diff --git a/package-lock.json b/package-lock.json index d47ed81..04bcb5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -128,6 +128,7 @@ "autoprefixer": "^10.4.24", "babel-jest": "^30.3.0", "commitizen": "^4.3.1", + "concurrently": "^9.2.1", "cosmiconfig": "^9.0.1", "cz-conventional-changelog": "^3.3.0", "eslint": "^9.39.2", @@ -13337,6 +13338,97 @@ "dev": true, "license": "MIT" }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/concurrently/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/concurrently/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/conventional-commit-types": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/conventional-commit-types/-/conventional-commit-types-3.0.0.tgz", @@ -27335,6 +27427,19 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -28178,6 +28283,16 @@ "node": ">=18" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", diff --git a/package.json b/package.json index d21d910..8953b61 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "author": "ZYNC Team", "description": "ZYNC Desktop Application", "scripts": { - "dev": "vite --force", + "dev": "concurrently -k -n frontend,backend \"npm run dev:frontend\" \"npm run dev:backend\"", + "dev:frontend": "vite --force", + "dev:backend": "npm run dev --prefix backend", "build": "vite build", "build:dev": "vite build --mode development", "lint": "eslint . --report-unused-disable-directives", @@ -143,6 +145,7 @@ "autoprefixer": "^10.4.24", "babel-jest": "^30.3.0", "commitizen": "^4.3.1", + "concurrently": "^9.2.1", "cosmiconfig": "^9.0.1", "cz-conventional-changelog": "^3.3.0", "eslint": "^9.39.2", diff --git a/src/components/views/ActivityLogView.tsx b/src/components/views/ActivityLogView.tsx index 765a017..712827a 100644 --- a/src/components/views/ActivityLogView.tsx +++ b/src/components/views/ActivityLogView.tsx @@ -35,9 +35,9 @@ import { getLogoById, getDeterministicLogoId } from '@/lib/team-logos'; /** Design tokens โ€” Activity Log page only */ const T = { bgBase: '#020617', // Deep slate/black - bgCard: 'rgba(15, 23, 42, 0.65)', - bgSurface: 'rgba(30, 41, 59, 0.4)', - border: 'rgba(255, 255, 255, 0.08)', + bgCard: 'rgba(255, 255, 255, 0.04)', + bgSurface: 'rgba(255, 255, 255, 0.06)', + border: 'rgba(255, 255, 255, 0.12)', blue: '#3b82f6', green: '#10b981', orange: '#f59e0b', @@ -78,8 +78,15 @@ interface ActivityLogViewProps { users?: any[]; teamSessions?: any[]; currentTeamId?: string; + currentTeamName?: string; + currentTeamOwnerId?: string; + currentTeamLogoId?: string; ownedTeams?: any[]; + myTeams?: any[]; currentUserId?: string; + currentUserDisplayName?: string; + currentUserPhotoURL?: string | null; + currentUserEmail?: string; } function normStatus(s: unknown): string { @@ -88,6 +95,28 @@ function normStatus(s: unknown): string { .toLowerCase(); } +function normalizeUid(value: any): string { + if (!value) return ''; + if (typeof value === 'string') return value; + if (typeof value === 'object') { + return String(value.uid || value.id || value._id || ''); + } + return String(value); +} + +function extractOwnerUid(team: any): string { + return normalizeUid( + team?.ownerId || + team?.ownerUid || + team?.leaderId || + team?.createdBy || + team?.createdByUid || + team?.owner?.uid || + team?.owner?.id || + team?.owner?._id + ); +} + function isCompletedTask(t: any): boolean { const s = normStatus(t?.status); // Auto-ticked implies status is 'done' or 'complete' @@ -174,34 +203,205 @@ export default function ActivityLogView({ users = [], teamSessions = [], currentTeamId, + currentTeamName, + currentTeamOwnerId, + currentTeamLogoId, ownedTeams = [], + myTeams: myTeamsFromApi = [], currentUserId, + currentUserDisplayName, + currentUserPhotoURL, + currentUserEmail, }: ActivityLogViewProps) { - const { myTeams, loading: teamsLoading } = useTeamPersistence(currentUserId); + const { myTeams: myTeamsFromHook, loading: teamsLoading, syncTeamsFromApi } = useTeamPersistence(currentUserId); + const normalizedCurrentUserId = useMemo(() => normalizeUid(currentUserId), [currentUserId]); + + useEffect(() => { + if (!normalizedCurrentUserId) return; + // Keep Firestore teams aligned with backend source of truth to avoid owner/member mismatch. + syncTeamsFromApi([...(Array.isArray(ownedTeams) ? ownedTeams : []), ...(Array.isArray(myTeamsFromApi) ? myTeamsFromApi : [])], normalizedCurrentUserId); + }, [syncTeamsFromApi, normalizedCurrentUserId, ownedTeams, myTeamsFromApi]); + + const mergedOwnedTeams = useMemo(() => { + const map = new Map(); + (Array.isArray(ownedTeams) ? ownedTeams : []).forEach((t: any) => { + const id = t?.id || t?._id || t?.teamId; + if (!id) return; + const prev = map.get(id) || {}; + map.set(id, { + ...prev, + ...t, + id, + name: t?.name || prev?.name || 'Team', + }); + }); + return Array.from(map.values()); + }, [ownedTeams]); + + const mergedMyTeams = useMemo(() => { + const map = new Map(); + [...(Array.isArray(myTeamsFromApi) ? myTeamsFromApi : []), ...(Array.isArray(myTeamsFromHook) ? myTeamsFromHook : [])].forEach((t: any) => { + const id = t?.id || t?._id; + if (!id) return; + const prev = map.get(id) || {}; + map.set(id, { ...prev, ...t, id }); + }); + return Array.from(map.values()); + }, [myTeamsFromApi, myTeamsFromHook]); // Merge API teams and Firestore teams const allTeams = useMemo(() => { - const map = new Map(); - ownedTeams.forEach(t => map.set(t.id || t._id, { id: t.id || t._id, name: t.name, leaderId: t.leaderId || currentUserId })); - myTeams.forEach(t => map.set(t.id, t)); + const map = new Map(); + + mergedOwnedTeams.forEach((t: any) => { + const id = t?.id || t?._id; + if (!id) return; + const prev = map.get(id) || {}; + map.set(id, { + ...prev, + ...t, + id, + name: t?.name || prev?.name || 'Team', + leaderId: extractOwnerUid(t) || prev?.leaderId || normalizedCurrentUserId, + ownerId: extractOwnerUid(t) || prev?.ownerId, + ownerUid: extractOwnerUid(t) || prev?.ownerUid, + }); + }); + + mergedMyTeams.forEach((t: any) => { + const id = t?.id || t?._id; + if (!id) return; + const prev = map.get(id) || {}; + map.set(id, { + ...prev, + ...t, + id, + name: t?.name || prev?.name || 'Team', + leaderId: extractOwnerUid(t) || prev?.leaderId, + ownerId: extractOwnerUid(t) || prev?.ownerId, + ownerUid: extractOwnerUid(t) || prev?.ownerUid, + }); + }); + + // Fallback: inject currently selected team from /users/me payload when team APIs fail. + if (currentTeamId && !map.has(currentTeamId)) { + map.set(currentTeamId, { + id: currentTeamId, + name: currentTeamName || 'My Team', + ownerId: normalizeUid(currentTeamOwnerId), + leaderId: normalizeUid(currentTeamOwnerId), + logoId: currentTeamLogoId, + }); + } + return Array.from(map.values()); - }, [ownedTeams, myTeams, currentUserId]); + }, [mergedOwnedTeams, mergedMyTeams, normalizedCurrentUserId, currentTeamId, currentTeamName, currentTeamOwnerId, currentTeamLogoId]); + + const ownedTeamIdSet = useMemo( + () => + new Set( + (Array.isArray(mergedOwnedTeams) ? mergedOwnedTeams : []) + .map((t: any) => t?.id || t?._id) + .filter(Boolean) + ), + [mergedOwnedTeams] + ); + const leaderTeams = useMemo( + () => + allTeams.filter((t: any) => { + const id = t?.id || t?._id; + if (id && ownedTeamIdSet.has(id)) return true; + const owner = extractOwnerUid(t); + return Boolean(normalizedCurrentUserId) && owner === normalizedCurrentUserId; + }), + [allTeams, normalizedCurrentUserId, ownedTeamIdSet] + ); + const teamFilterOptions = useMemo( + () => { + const fromOwned = (Array.isArray(mergedOwnedTeams) ? mergedOwnedTeams : []) + .map((t: any) => ({ + ...t, + id: String(t?.id || t?._id || t?.teamId || ''), + name: t?.name || 'My Team', + })) + .filter((t: any) => Boolean(t.id)); + const fromMineOwned = (Array.isArray(mergedMyTeams) ? mergedMyTeams : []) + .map((t: any) => ({ + ...t, + id: String(t?.id || t?._id || t?.teamId || ''), + name: t?.name || 'My Team', + })) + .filter((t: any) => { + if (!t.id || !normalizedCurrentUserId) return false; + const owner = extractOwnerUid(t); + return Boolean(owner) && owner === normalizedCurrentUserId; + }); - const isLeader = (ownedTeams && ownedTeams.length > 0) || myTeams.some(t => t.leaderId === currentUserId); - const [selectedTeamId, setSelectedTeamId] = useState(currentTeamId || 'all'); - const [selectedUserId, setSelectedUserId] = useState(currentUserId || 'all'); + if (leaderTeams.length > 0) { + return leaderTeams; + } + if (fromOwned.length > 0) { + return fromOwned; + } + if (fromMineOwned.length > 0) { + return fromMineOwned; + } + const normalizedCurrentTeamOwner = normalizeUid(currentTeamOwnerId); + if (currentTeamId && normalizedCurrentTeamOwner && normalizedCurrentTeamOwner === normalizedCurrentUserId) { + return [{ + id: String(currentTeamId), + name: currentTeamName || 'My Team', + ownerId: normalizedCurrentTeamOwner, + leaderId: normalizedCurrentTeamOwner, + logoId: currentTeamLogoId, + }]; + } + return []; + }, + [leaderTeams, mergedOwnedTeams, mergedMyTeams, currentTeamId, currentTeamName, currentTeamOwnerId, currentTeamLogoId, normalizedCurrentUserId] + ); + + const normalizedTeamFilterOptions = useMemo(() => { + const map = new Map(); + teamFilterOptions.forEach((t: any) => { + const id = String(t?.id || t?._id || t?.teamId || ''); + if (!id) return; + map.set(id, { + ...t, + id, + name: t?.name || 'My Team', + }); + }); + return Array.from(map.values()); + }, [teamFilterOptions]); + const canShowTeamFilter = normalizedTeamFilterOptions.length > 0; + const isLeader = canShowTeamFilter; + const [selectedTeamId, setSelectedTeamId] = useState('all'); + const [selectedUserId, setSelectedUserId] = useState(currentUserId || 'all'); const [searchQuery, setSearchQuery] = useState(''); const [showAllLogs, setShowAllLogs] = useState(false); const [taskAnalyticsThisMonth, setTaskAnalyticsThisMonth] = useState(false); useEffect(() => { - if (currentTeamId) { - setSelectedTeamId(currentTeamId); - } else if (selectedTeamId === 'all' && allTeams.length > 0) { - setSelectedTeamId(allTeams[0].id); + setSelectedUserId(currentUserId || 'all'); + }, [currentUserId]); + + useEffect(() => { + if (selectedTeamId === 'all' && selectedUserId === 'all' && currentUserId) { + setSelectedUserId(currentUserId); } - }, [currentTeamId, allTeams]); + }, [selectedTeamId, selectedUserId, currentUserId]); + + useEffect(() => { + if (selectedTeamId === 'all') return; + // Keep current selection during transient reload/rate-limit gaps. + if (normalizedTeamFilterOptions.length === 0) return; + if (!normalizedTeamFilterOptions.some((t: any) => t.id === selectedTeamId)) { + setSelectedTeamId('all'); + setSelectedUserId(currentUserId || 'all'); + } + }, [selectedTeamId, normalizedTeamFilterOptions, currentUserId]); // Persistence for task progress const { stats: persistedStats, saveStats } = useTaskPersistence(selectedUserId === 'all' ? undefined : selectedUserId); @@ -209,13 +409,105 @@ export default function ActivityLogView({ const activeUser = useMemo(() => { if (selectedUserId === 'all') return null; const found = users.find(u => u.uid === selectedUserId); - if (found) return found; + if (found) { + if (selectedUserId === currentUserId) { + return { + ...found, + displayName: found.displayName || currentUserDisplayName || 'You', + email: found.email || currentUserEmail, + photoURL: found.photoURL || currentUserPhotoURL || null, + }; + } + return found; + } if (selectedUserId === currentUserId) { - // Fallback to minimal current user info if not in users list - return { uid: currentUserId, displayName: 'You', photoURL: null }; + // Fallback to current authenticated user info if not in users list + return { + uid: currentUserId, + displayName: currentUserDisplayName || currentUserEmail?.split('@')[0] || 'You', + email: currentUserEmail, + photoURL: currentUserPhotoURL || null + }; + } + // Secondary fallback: resolve from selected team's embedded member objects. + const selectedTeam = allTeams.find((t: any) => t.id === selectedTeamId); + const rawMember = (selectedTeam?.members || []).find((m: any) => { + if (!m) return false; + if (typeof m === 'string') return m === selectedUserId; + const candidateId = m.uid || m.userId || m.id || m._id; + return candidateId === selectedUserId; + }); + if (rawMember && typeof rawMember === 'object') { + return { + uid: rawMember.uid || rawMember.userId || rawMember.id || rawMember._id || selectedUserId, + displayName: rawMember.displayName || rawMember.name || rawMember.email?.split('@')[0] || 'Member', + email: rawMember.email, + photoURL: rawMember.photoURL || rawMember.avatar || null, + }; } return null; - }, [selectedUserId, users, currentUserId]); + }, [selectedUserId, users, currentUserId, currentUserDisplayName, currentUserEmail, currentUserPhotoURL, allTeams, selectedTeamId]); + + const selectedTeamMemberOptions = useMemo(() => { + if (selectedTeamId === 'all') return []; + const team = allTeams.find((t: any) => t.id === selectedTeamId); + if (!team) return []; + + const teamUids = new Set(); + (team.members || []).forEach((member: any) => { + const uid = typeof member === 'string' ? member : member?.uid || member?.userId || member?.id || member?._id; + if (uid) teamUids.add(uid); + }); + [team.ownerId, team.ownerUid, team.leaderId].forEach((uid: string) => { + if (uid) teamUids.add(uid); + }); + + // Prefer enriched user profiles when available. + const fromUsers = users + .filter((u: any) => { + const hasMembership = u.teamMemberships?.includes(selectedTeamId) || (u as any).teamId === selectedTeamId; + return teamUids.has(u.uid) || hasMembership; + }) + .map((u: any) => ({ + uid: u.uid, + label: u.displayName || u.email?.split('@')[0] || u.uid, + photoURL: u.photoURL || null, + })); + + const map = new Map(); + fromUsers.forEach((u: { uid: string; label: string; photoURL?: string | null }) => map.set(u.uid, u)); + + // Include embedded member objects (when API sends rich member entries on team payload). + (team.members || []).forEach((member: any) => { + if (!member || typeof member === 'string') return; + const uid = member.uid || member.userId || member.id || member._id; + if (!uid || map.has(uid)) return; + map.set(uid, { + uid, + label: member.displayName || member.name || member.email?.split('@')[0] || uid, + photoURL: member.photoURL || member.avatar || null, + }); + }); + + // Fallback when /api/users is rate-limited: show UID-based options so filtering still works. + teamUids.forEach((uid) => { + if (!map.has(uid)) { + map.set(uid, { uid, label: uid === currentUserId ? 'You' : uid, photoURL: null }); + } + }); + + return Array.from(map.values()); + }, [selectedTeamId, allTeams, users, currentUserId]); + + const selectedTeamOption = useMemo( + () => normalizedTeamFilterOptions.find((t: any) => t.id === selectedTeamId), + [normalizedTeamFilterOptions, selectedTeamId] + ); + + const selectedMemberOption = useMemo( + () => selectedTeamMemberOptions.find((u: any) => u.uid === selectedUserId), + [selectedTeamMemberOptions, selectedUserId] + ); const doughnutCanvasRef = useRef(null); const barCanvasRef = useRef(null); @@ -247,17 +539,19 @@ export default function ActivityLogView({ const hasCommitCode = Boolean(task?.commitCode); if (!hasRepoLink || !hasCommitCode) return false; + // Always honor explicit user selection first (including default self view) + if (selectedUserId !== 'all') { + const assignedTo = task?.assignedTo; + const assignedUserIds = Array.isArray(task?.assignedUserIds) ? task.assignedUserIds : []; + return assignedTo === selectedUserId || assignedUserIds.includes(selectedUserId); + } + if (selectedTeamId !== 'all') { // Check if the user is a member of the selected team const isMemberOfSelectedTeam = users.find(u => u.uid === selectedUserId)?.teamId === selectedTeamId; // Fallback const assignedTo = task?.assignedTo; const assignedUserIds = Array.isArray(task?.assignedUserIds) ? task.assignedUserIds : []; - - // If filtering by specific user, check if they match - if (selectedUserId !== 'all') { - return assignedTo === selectedUserId || assignedUserIds.includes(selectedUserId); - } // If filtering by specific team, check if assigned users are in that team const isUserInTeam = (uid: string) => { @@ -758,9 +1052,10 @@ export default function ActivityLogView({ className="al-fade-up rounded-[24px] border p-8 space-y-8 shadow-2xl relative overflow-hidden" style={{ animationDelay: '0.1s', - background: 'linear-gradient(135deg, rgba(15, 23, 42, 0.9) 0%, rgba(2, 6, 23, 0.9) 100%)', - borderColor: 'rgba(255,255,255,0.1)', - backdropFilter: 'blur(20px)' + background: 'rgba(255, 255, 255, 0.04)', + borderColor: 'rgba(255,255,255,0.12)', + backdropFilter: 'blur(12px)', + boxShadow: 'none' }} >
@@ -769,48 +1064,117 @@ export default function ActivityLogView({ Activity Summary
- {isLeader && ( -
- { + setSelectedTeamId(v); + if (v === 'all') { + setSelectedUserId(currentUserId || 'all'); + } else { + setSelectedUserId('all'); + } + }} + > + + {selectedTeamId !== 'all' && selectedTeamOption ? ( + (() => { + const logoId = selectedTeamOption.logoId || getDeterministicLogoId(selectedTeamOption.id); + const { icon: LogoIcon, fgColor, bgColor, borderColor } = getLogoById(logoId); + return ( +
+ + + + {selectedTeamOption.name} +
+ ); + })() + ) : ( -
- - {allTeams.map(t => { + )} + + + {normalizedTeamFilterOptions.length === 0 ? ( + + No teams available + + ) : ( + normalizedTeamFilterOptions.map((t: any) => { const logoId = t.logoId || getDeterministicLogoId(t.id); - const { icon: LogoIcon } = getLogoById(logoId); + const { icon: LogoIcon, fgColor, bgColor, borderColor } = getLogoById(logoId); return ( - +
- + + + {t.name}
); - })} -
- - + }) + )} +
+ + + {selectedTeamId !== 'all' && ( -
- )} + )} +
{new Date().getFullYear()} @@ -824,16 +1188,16 @@ export default function ActivityLogView({
-
+
{selectedUserId !== 'all' ? ( activeUser?.photoURL ? ( Profile ) : ( -
+
{(activeUser?.displayName || 'Z').charAt(0)}
) @@ -841,8 +1205,15 @@ export default function ActivityLogView({ (() => { const team = allTeams.find(t => t.id === selectedTeamId); const logoId = team?.logoId || getDeterministicLogoId(selectedTeamId || 'default'); - const { icon: LogoIcon } = getLogoById(logoId); - return ; + const { icon: LogoIcon, fgColor, bgColor, borderColor } = getLogoById(logoId); + return ( + + + + ); })() )}
@@ -852,6 +1223,9 @@ export default function ActivityLogView({ ? (selectedTeamId === 'all' ? 'Organization Overview' : `${allTeams.find(t => t.id === selectedTeamId)?.name || 'Team'} Overview`) : (activeUser?.displayName || 'Member Profile')} + {selectedUserId !== 'all' && activeUser?.email && ( +

{activeUser.email}

+ )}

{taskStats.completed} Tasks completed

@@ -1148,8 +1522,15 @@ export default function ActivityLogView({ > {item.logoId ? ( (() => { - const { icon: CustomIcon } = getLogoById(item.logoId); - return ; + const { icon: CustomIcon, fgColor, bgColor, borderColor } = getLogoById(item.logoId); + return ( + + + + ); })() ) : ( item.tag === 'Session' ? 'โฑ' : item.tag[0] diff --git a/src/components/views/CreateTeamDialog.tsx b/src/components/views/CreateTeamDialog.tsx index 93211e8..0f30e4c 100644 --- a/src/components/views/CreateTeamDialog.tsx +++ b/src/components/views/CreateTeamDialog.tsx @@ -67,12 +67,12 @@ export const CreateTeamDialog = ({ open, onOpenChange, onSuccess }: CreateTeamDi toast.success("Team created successfully!"); // Sync to Firestore for persistent analytics - if (data.team && auth.currentUser) { + if (data && auth.currentUser) { createTeamSync( - data.team.id || data.team._id, - data.team.name, + data.id || data._id, + data.name, auth.currentUser.uid, - data.team.inviteCode, + data.inviteCode, selectedLogoId ); } @@ -174,10 +174,16 @@ export const CreateTeamDialog = ({ open, onOpenChange, onSuccess }: CreateTeamDi onClick={() => setSelectedLogoId(logo.id)} title={logo.label} > - + + + ); })} diff --git a/src/components/views/DesktopView.tsx b/src/components/views/DesktopView.tsx index 9bb88e5..b1ba9ef 100644 --- a/src/components/views/DesktopView.tsx +++ b/src/components/views/DesktopView.tsx @@ -290,6 +290,7 @@ const DesktopView = ({ isPreview = false }: { isPreview?: boolean }) => { const [teamSessions, setTeamSessions] = useState([]); const [ownedTeams, setOwnedTeams] = useState([]); const [myTeams, setMyTeams] = useState([]); + const activityFetchLastRunRef = useRef(0); const buildActivityLogTasks = (projects: any[]) => { return projects.flatMap((project: any) => @@ -409,6 +410,11 @@ const DesktopView = ({ isPreview = false }: { isPreview?: boolean }) => { let cancelled = false; const fetchData = async () => { + const now = Date.now(); + // Prevent burst re-fetches when dependent state updates rapidly. + if (now - activityFetchLastRunRef.current < 20_000) return; + activityFetchLastRunRef.current = now; + try { const token = await currentUser.getIdToken(); @@ -439,12 +445,22 @@ const DesktopView = ({ isPreview = false }: { isPreview?: boolean }) => { if (ownedTeamsRes.ok) { const teamsData = await ownedTeamsRes.json(); - setOwnedTeams(teamsData); + setOwnedTeams(Array.isArray(teamsData) ? teamsData : []); } if (myTeamsRes.ok) { const myTeamsData = await myTeamsRes.json(); - setMyTeams(myTeamsData); + const normalizedMyTeams = Array.isArray(myTeamsData) ? myTeamsData : []; + setMyTeams(normalizedMyTeams); + + // Fallback for rate-limited /owned endpoint: derive owner teams from /mine. + if (!ownedTeamsRes.ok) { + const ownerTeamsFromMine = normalizedMyTeams.filter((team: any) => { + const owner = team?.ownerId || team?.ownerUid || team?.leaderId || team?.createdBy || team?.createdByUid; + return owner === currentUser.uid; + }); + setOwnedTeams(ownerTeamsFromMine); + } } // Fetch team-member specific sessions if needed @@ -581,7 +597,7 @@ const DesktopView = ({ isPreview = false }: { isPreview?: boolean }) => { useEffect(() => { - if ((activeSection === "People" || activeSection === "Notes" || activeSection === "Chat" || activeSection === "Meet" || activeSection === "Tasks") && !isPreview) { + if ((activeSection === "People" || activeSection === "Notes" || activeSection === "Chat" || activeSection === "Meet" || activeSection === "Tasks" || activeSection === "Activity log") && !isPreview) { const fetchUsers = async () => { try { if (!currentUser) { return; } @@ -593,7 +609,7 @@ const DesktopView = ({ isPreview = false }: { isPreview?: boolean }) => { }); if (response.ok) { const data = await response.json(); - setUsersList(data); + setUsersList(Array.isArray(data) ? data : []); } else { const errData = await response.json().catch(() => ({})); console.error(`Error fetching users: ${response.status} ${response.statusText}`, errData); @@ -724,9 +740,7 @@ const DesktopView = ({ isPreview = false }: { isPreview?: boolean }) => { case "Activity log": return ( -
-
-
+
{ tasks={leaderTasks} users={usersList} teamSessions={teamSessions} - currentTeamId={typeof userData?.teamId === 'object' ? userData?.teamId?.id : userData?.teamId} - ownedTeams={myTeams} + currentTeamId={typeof userData?.teamId === 'object' ? (userData?.teamId?.id || userData?.teamId?._id) : userData?.teamId} + currentTeamName={typeof userData?.teamId === 'object' ? userData?.teamId?.name : undefined} + currentTeamOwnerId={typeof userData?.teamId === 'object' ? (userData?.teamId?.ownerId || userData?.teamId?.ownerUid || userData?.teamId?.leaderId) : undefined} + currentTeamLogoId={typeof userData?.teamId === 'object' ? userData?.teamId?.logoId : undefined} + ownedTeams={ownedTeams} + myTeams={myTeams} currentUserId={currentUser?.uid} + currentUserDisplayName={getUserName(pickUserForDisplay(userData, currentUser))} + currentUserPhotoURL={currentUser?.photoURL || userData?.photoURL || null} + currentUserEmail={currentUser?.email || userData?.email || undefined} />
@@ -820,7 +841,7 @@ const DesktopView = ({ isPreview = false }: { isPreview?: boolean }) => { onCollapse={() => setIsCollapsed(true)} onExpand={() => setIsCollapsed(false)} className={cn( - "bg-[#0F0F10] flex flex-col transition-all duration-300 ease-in-out h-full border-none", + "relative bg-black flex flex-col transition-all duration-300 ease-in-out h-full border-none after:content-[''] after:absolute after:top-0 after:-right-1 after:h-full after:w-2 after:bg-black after:pointer-events-none after:z-[90]", isCollapsed && "min-w-[70px]", // Animation logic: Hidden during landing, slides in when landing finishes isLanding ? "opacity-0 invisible" : "" @@ -909,22 +930,26 @@ const DesktopView = ({ isPreview = false }: { isPreview?: boolean }) => {
+ {/* Hard mask to remove sidebar/content seam */} +
- + {/* Main Content Panel - The "Card" Look */} -
-
+
+
+ {/* Matching left-edge mask to eliminate antialias seam */} +
{/* Background Gradients - Inside the Rounded Container */}
-
+ {/* Header - Always show for main app content */} -
+

{activeSection}

diff --git a/src/components/views/JoinTeamDialog.tsx b/src/components/views/JoinTeamDialog.tsx index 6a5984d..b91e3de 100644 --- a/src/components/views/JoinTeamDialog.tsx +++ b/src/components/views/JoinTeamDialog.tsx @@ -39,12 +39,12 @@ export const JoinTeamDialog = ({ open, onOpenChange, onSuccess }: JoinTeamDialog } return data; }, - onSuccess: () => { + onSuccess: (data) => { toast.success("Joined team successfully!"); // Sync to Firestore for persistent analytics if (auth.currentUser) { - joinTeamSync(inviteCode, auth.currentUser.uid); + joinTeamSync(data || inviteCode, auth.currentUser.uid); } // Invalidate queries to refresh UI diff --git a/src/components/views/PeopleView.tsx b/src/components/views/PeopleView.tsx index cdf839b..72c90cc 100644 --- a/src/components/views/PeopleView.tsx +++ b/src/components/views/PeopleView.tsx @@ -22,6 +22,7 @@ import { Label } from "@/components/ui/label"; import { useToast } from "@/hooks/use-toast"; import { formatDistanceToNow } from "date-fns"; import MessagesPage from "./MessagesPage"; +import { getLogoById, getDeterministicLogoId } from "@/lib/team-logos"; /** Format current time in a given IANA timezone (e.g. "America/New_York") */ function formatLocalTime(timezone: string | null | undefined): string | null { @@ -272,7 +273,7 @@ const PeopleView = ({ users: propUsers, userStatuses, onChat, isPreview }: Peopl const isFloating = isCollapsed && isHovered; return ( -
+
{}
{myTeams.map((team) => ( -
{ - setTeamInfo(team); - setShowMessages(false); - }} - className={cn( - "flex items-center rounded-md transition-all cursor-pointer select-none border border-transparent", - effectiveCollapsed ? "justify-center px-0 py-2" : "px-2 py-1.5 text-sm", - teamInfo?.id === team.id - ? "bg-secondary/80 text-foreground border-border/50 shadow-sm" - : "text-muted-foreground hover:bg-secondary/50 hover:text-foreground" - )} - > - {!effectiveCollapsed && {team.name}} - {effectiveCollapsed && {team.name.substring(0, 2).toUpperCase()}} + (() => { + const logoId = team.logoId || getDeterministicLogoId(team.id); + const { icon: TeamLogoIcon, fgColor, bgColor, borderColor } = getLogoById(logoId); + return ( +
{ + setTeamInfo(team); + setShowMessages(false); + }} + className={cn( + "flex items-center rounded-md transition-all cursor-pointer select-none border border-transparent", + effectiveCollapsed ? "justify-center px-0 py-2" : "px-2 py-1.5 text-sm", + teamInfo?.id === team.id + ? "bg-secondary/80 text-foreground border-border/50 shadow-sm" + : "text-muted-foreground hover:bg-secondary/50 hover:text-foreground" + )} + > + + + + {!effectiveCollapsed && ( +
+

{team.name}

+
+ )} + {effectiveCollapsed && {team.name.substring(0, 2).toUpperCase()}} - {!effectiveCollapsed && teamInfo?.id === team.id && ( -
- )} -
+ {!effectiveCollapsed && teamInfo?.id === team.id && ( +
+ )} +
+ ); + })() ))} {myTeams.length === 0 && ( @@ -458,19 +478,14 @@ const PeopleView = ({ users: propUsers, userStatuses, onChat, isPreview }: Peopl {}
{} -
-
-

People

-

Manage your team

-
-
- {} -
+
@@ -507,7 +522,7 @@ const PeopleView = ({ users: propUsers, userStatuses, onChat, isPreview }: Peopl {} - @@ -574,17 +589,37 @@ const PeopleView = ({ users: propUsers, userStatuses, onChat, isPreview }: Peopl
-
+
-

{teamInfo?.name || "Your Team"}

+ {teamInfo && (() => { + const logoId = teamInfo.logoId || getDeterministicLogoId(teamInfo.id); + const { icon: TeamLogoIcon, label: teamLogoLabel, fgColor, bgColor, borderColor } = getLogoById(logoId); + return ( +
+
+ +
+
+

{teamInfo.name || "Your Team"}

+

{teamLogoLabel}

+
+
+ ); + })()} + {!teamInfo && ( +

Your Team

+ )} {teamInfo && (
Invite Code:
{ navigator.clipboard.writeText(teamInfo.inviteCode); toast({ description: "Invite code copied to clipboard" }); @@ -670,7 +705,7 @@ const PeopleView = ({ users: propUsers, userStatuses, onChat, isPreview }: Peopl return ( {} @@ -739,13 +774,13 @@ const PeopleView = ({ users: propUsers, userStatuses, onChat, isPreview }: Peopl
- {}
diff --git a/src/components/views/SettingsView.tsx b/src/components/views/SettingsView.tsx index 603ed12..532b71e 100644 --- a/src/components/views/SettingsView.tsx +++ b/src/components/views/SettingsView.tsx @@ -549,15 +549,10 @@ export default function SettingsView() { }; return ( -
+
-
-

Settings

-

Manage your account settings and preferences.

-
- - + My Profile Team Preferences @@ -568,7 +563,7 @@ export default function SettingsView() { {} - + Personal Information Update your personal details here. @@ -699,7 +694,7 @@ export default function SettingsView() { {} - + Connected Accounts Manage your external connections to sync projects. @@ -755,7 +750,7 @@ export default function SettingsView() { {} - + App Preferences
@@ -789,7 +784,7 @@ export default function SettingsView() {
{} - + Get in Touch You can reach us anytime @@ -825,7 +820,7 @@ export default function SettingsView() { {}
- +
@@ -839,7 +834,7 @@ export default function SettingsView() { - +
@@ -853,7 +848,7 @@ export default function SettingsView() { - +
@@ -923,6 +918,7 @@ function TeamTabContent({ currentUser, userData, teamsData, setTeamsData, teamLo const [actionLoading, setActionLoading] = useState(false); const [selectedTeamId, setSelectedTeamId] = useState(null); const [teamNameDraft, setTeamNameDraft] = useState(""); + const [isEditingTeamName, setIsEditingTeamName] = useState(false); const [renameSaved, setRenameSaved] = useState(false); const teamNameInputRef = useRef(null); const renameSavedTimerRef = useRef(null); @@ -938,11 +934,12 @@ function TeamTabContent({ currentUser, userData, teamsData, setTeamsData, teamLo useEffect(() => { setTeamNameDraft(selectedTeam?.name || ""); + setIsEditingTeamName(false); setRenameSaved(false); }, [selectedTeamId, selectedTeam?.name]); useEffect(() => { - if (!selectedTeam || selectedTeam.ownerId !== currentUser?.uid) { + if (!selectedTeam || !isEditingTeamName) { return; } @@ -952,7 +949,7 @@ function TeamTabContent({ currentUser, userData, teamsData, setTeamsData, teamLo }, 0); return () => window.clearTimeout(focusTimer); - }, [selectedTeamId, selectedTeam, currentUser?.uid]); + }, [selectedTeamId, selectedTeam, currentUser?.uid, isEditingTeamName]); useEffect(() => { return () => { @@ -1163,12 +1160,18 @@ function TeamTabContent({ currentUser, userData, teamsData, setTeamsData, teamLo const handleRenameTeam = async (teamId: string) => { if (!currentUser) return; - const nextName = teamNameDraft.trim(); + const currentName = selectedTeam?.name?.trim() || ""; + const typedName = teamNameDraft.trim(); + const nextName = typedName || currentName; + if (!nextName) { toast({ title: "Invalid Name", description: "Team name cannot be empty.", variant: "destructive" }); return; } - if (nextName === selectedTeam?.name) { + + if (nextName === currentName) { + setTeamNameDraft(currentName); + setIsEditingTeamName(false); return; } @@ -1201,6 +1204,7 @@ function TeamTabContent({ currentUser, userData, teamsData, setTeamsData, teamLo ); setRenameSaved(true); + setIsEditingTeamName(false); renameSavedTimerRef.current = window.setTimeout(() => { setRenameSaved(false); renameSavedTimerRef.current = null; @@ -1252,7 +1256,7 @@ function TeamTabContent({ currentUser, userData, teamsData, setTeamsData, teamLo const tid = team.id || team._id; const isSelected = selectedTeamId === tid; const logoId = team.logoId || getDeterministicLogoId(tid); - const { icon: LogoIcon } = getLogoById(logoId); + const { icon: LogoIcon, fgColor, bgColor, borderColor } = getLogoById(logoId); const isOwner = team.ownerId === currentUser?.uid; return ( @@ -1266,11 +1270,15 @@ function TeamTabContent({ currentUser, userData, teamsData, setTeamsData, teamLo >
-
- +
+

{team.name}

@@ -1294,13 +1302,18 @@ function TeamTabContent({ currentUser, userData, teamsData, setTeamsData, teamLo
-
- {(() => { - const lid = selectedTeam.logoId || getDeterministicLogoId(selectedTeam.id); - const { icon: LI } = getLogoById(lid); - return
  • ; - })()} -
  • + {(() => { + const lid = selectedTeam.logoId || getDeterministicLogoId(selectedTeam.id); + const { icon: LI, fgColor, bgColor, borderColor } = getLogoById(lid); + return ( +
    +
  • +
  • + ); + })()}
    {selectedTeam.name} {selectedTeam.type} ยท {selectedTeam.members?.length || 0} member{selectedTeam.members?.length !== 1 ? 's' : ''} @@ -1336,14 +1349,29 @@ function TeamTabContent({ currentUser, userData, teamsData, setTeamsData, teamLo onChange={(e) => setTeamNameDraft(e.target.value)} placeholder="Enter team name" maxLength={80} + readOnly={!isEditingTeamName} /> - + {isEditingTeamName ? ( + + ) : ( + + )}
    )} diff --git a/src/hooks/use-activity-tracker.ts b/src/hooks/use-activity-tracker.ts index 0450001..3bbc40a 100644 --- a/src/hooks/use-activity-tracker.ts +++ b/src/hooks/use-activity-tracker.ts @@ -14,22 +14,16 @@ export const useActivityTracker = () => { const unsubscribe = auth.onAuthStateChanged(async (user) => { if (user && !sessionIdRef.current) { + // DesktopView already owns session start. + // Reuse the persisted active session to avoid duplicate /sessions/start calls. try { - const token = await user.getIdToken(); - const res = await fetch(`${API_BASE_URL}/api/sessions/start`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify({ userId: user.uid }), - }); - if (res.ok) { - const session = await res.json(); - sessionIdRef.current = session._id; + const raw = localStorage.getItem('currentSession'); + if (raw) { + const parsed = JSON.parse(raw); + sessionIdRef.current = parsed?.id || null; } - } catch (e) { - console.error("Failed to start session:", e); + } catch { + sessionIdRef.current = null; } } else if (!user) { sessionIdRef.current = null; diff --git a/src/hooks/use-user-sync.ts b/src/hooks/use-user-sync.ts index ff0b1eb..592912d 100644 --- a/src/hooks/use-user-sync.ts +++ b/src/hooks/use-user-sync.ts @@ -9,8 +9,14 @@ export const useUserSync = () => { const syncInProgress = useRef(false); useEffect(() => { + const shouldSyncInDev = + String(import.meta.env.VITE_ENABLE_DEV_USER_SYNC || "").toLowerCase() === "true"; + const unsubscribe = auth.onAuthStateChanged(async (user) => { if (user && !syncInProgress.current) { + if (import.meta.env.DEV && !shouldSyncInDev) { + return; + } syncInProgress.current = true; const displayName = user.displayName || ""; const parts = displayName.trim().split(" "); @@ -68,6 +74,9 @@ export const useUserSync = () => { // Do not invalidate here โ€” that would force an immediate refetch and defeat local cache. + } catch { + // Backend can be temporarily unavailable in local/dev setups. + // Keep UI functional and avoid noisy console errors. } finally { syncInProgress.current = false; } diff --git a/src/hooks/useNotePresence.ts b/src/hooks/useNotePresence.ts index 6e83ff3..e2d166d 100644 --- a/src/hooks/useNotePresence.ts +++ b/src/hooks/useNotePresence.ts @@ -1,6 +1,6 @@ import { useEffect, useState, useCallback, useRef } from 'react'; import { io, Socket } from 'socket.io-client'; -import { API_BASE_URL } from '@/lib/utils'; +import { API_BASE_URL, SOCKET_BASE_URL } from '@/lib/utils'; export interface ActiveUser { @@ -63,7 +63,7 @@ export const useNotePresence = ( } const userColor = getColorForUser(user.uid); - const socketUrl = import.meta.env.DEV ? 'http://localhost:5000' : API_BASE_URL; + const socketUrl = SOCKET_BASE_URL; console.log('[NotePresence] ๐Ÿ”Œ Socket URL:', socketUrl); diff --git a/src/hooks/usePresence.ts b/src/hooks/usePresence.ts index affff41..318b608 100644 --- a/src/hooks/usePresence.ts +++ b/src/hooks/usePresence.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { io, Socket } from 'socket.io-client'; -import { API_BASE_URL } from '@/lib/utils'; +import { SOCKET_BASE_URL } from '@/lib/utils'; export interface UserStatus { status: 'online' | 'offline' | 'away'; @@ -13,11 +13,14 @@ export const usePresence = (userId: string | undefined) => { useEffect(() => { if (!userId) {return;} - const socketUrl = import.meta.env.DEV ? "http://localhost:5000" : API_BASE_URL; + const socketUrl = SOCKET_BASE_URL; const socket = io(`${socketUrl}/presence`, { query: { userId }, - transports: ['websocket'] + transports: ['websocket', 'polling'], + reconnection: true, + reconnectionAttempts: 3, + reconnectionDelay: 1000, }); socket.on('connect', () => { diff --git a/src/hooks/useTaskPersistence.ts b/src/hooks/useTaskPersistence.ts index 851e442..f460d27 100644 --- a/src/hooks/useTaskPersistence.ts +++ b/src/hooks/useTaskPersistence.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { db } from "@/lib/firebase"; import { doc, getDoc, setDoc, onSnapshot } from "firebase/firestore"; @@ -14,6 +14,29 @@ export interface TaskStats { export const useTaskPersistence = (userId: string | undefined) => { const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); + const lastSavedFingerprintRef = useRef(""); + const lastSavedAtRef = useRef(0); + const writeCooldownUntilRef = useRef(0); + const cooldownWarningShownRef = useRef(false); + const SAVE_DEBOUNCE_MS = 30_000; + const QUOTA_COOLDOWN_MS = 10 * 60 * 1000; + const devWritesEnabled = import.meta.env.VITE_ENABLE_TASK_FIRESTORE_SYNC === "true"; + const allowFirestoreWrites = !import.meta.env.DEV || devWritesEnabled; + const quotaCooldownKey = userId ? `zync-task-sync-cooldown:${userId}` : ""; + + const normalizeStats = (input: TaskStats): TaskStats => ({ + total: Number(input?.total || 0), + inProgress: Number(input?.inProgress || 0), + completed: Number(input?.completed || 0), + overdue: Number(input?.overdue || 0), + efficiency: Number(input?.efficiency || 0), + dailyActiveAvg: Number(input?.dailyActiveAvg || 0), + }); + + const buildFingerprint = (input: TaskStats): string => { + const normalized = normalizeStats(input); + return JSON.stringify(normalized); + }; useEffect(() => { if (!userId) { @@ -21,6 +44,13 @@ export const useTaskPersistence = (userId: string | undefined) => { return; } + if (quotaCooldownKey) { + const persistedCooldown = Number(localStorage.getItem(quotaCooldownKey) || "0"); + if (persistedCooldown > Date.now()) { + writeCooldownUntilRef.current = persistedCooldown; + } + } + const docRef = doc(db, "tasks", userId); // Listen for real-time updates @@ -39,17 +69,51 @@ export const useTaskPersistence = (userId: string | undefined) => { return () => unsubscribe(); }, [userId]); - const saveStats = async (newStats: TaskStats) => { - if (!userId) return; + const saveStats = useCallback(async (newStats: TaskStats) => { + if (!userId || !allowFirestoreWrites) return; + + const now = Date.now(); + if (writeCooldownUntilRef.current > now) { + return; + } + + const normalized = normalizeStats(newStats); + const fingerprint = buildFingerprint(normalized); + + // Skip unchanged writes. + if (fingerprint === lastSavedFingerprintRef.current) { + return; + } + + // Coalesce frequent writes caused by rapid UI/state updates. + if (now - lastSavedAtRef.current < SAVE_DEBOUNCE_MS) { + return; + } + try { - await setDoc(doc(db, "tasks", userId), newStats, { merge: true }); + await setDoc(doc(db, "tasks", userId), normalized, { merge: true }); + lastSavedFingerprintRef.current = fingerprint; + lastSavedAtRef.current = now; + cooldownWarningShownRef.current = false; } catch (error) { + const code = (error as any)?.code || ""; + if (code === "resource-exhausted") { + writeCooldownUntilRef.current = now + QUOTA_COOLDOWN_MS; + if (quotaCooldownKey) { + localStorage.setItem(quotaCooldownKey, String(writeCooldownUntilRef.current)); + } + if (!cooldownWarningShownRef.current) { + console.warn("Firestore quota reached; pausing task stat writes temporarily."); + cooldownWarningShownRef.current = true; + } + return; + } console.error("Error saving task stats to Firestore:", error); } - }; + }, [userId, allowFirestoreWrites, quotaCooldownKey]); const markTaskOpened = async (taskId: string) => { - if (!userId) return; + if (!userId || !allowFirestoreWrites) return; // In Firestore, we should ideally track individual task statuses in a subcollection // but to satisfy "Overdue = user just opened the task" simply, we can increment a counter or track in a map try { diff --git a/src/hooks/useTeamPersistence.ts b/src/hooks/useTeamPersistence.ts index eb7cac4..acc02cc 100644 --- a/src/hooks/useTeamPersistence.ts +++ b/src/hooks/useTeamPersistence.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { db } from "@/lib/firebase"; import { doc, @@ -8,22 +8,66 @@ import { query, collection, where, - onSnapshot + onSnapshot, + getDocs } from "firebase/firestore"; export interface TeamMetadata { id: string; name: string; leaderId: string; + ownerId: string; members: string[]; inviteCode: string; logoId?: string; + updatedAt?: string; + createdAt?: string; } export const useTeamPersistence = (userId: string | undefined) => { const [myTeams, setMyTeams] = useState([]); const [loading, setLoading] = useState(true); + const normalizeUid = (value: any): string => { + if (!value) return ""; + if (typeof value === "string") return value; + if (typeof value === "object") return String(value.uid || value.id || value._id || ""); + return String(value); + }; + + const normalizeTeamPayload = (team: any, fallbackUserId?: string): TeamMetadata | null => { + const id = String(team?.id || team?._id || team?.teamId || ""); + if (!id) return null; + + const ownerId = normalizeUid( + team?.ownerId || + team?.ownerUid || + team?.leaderId || + team?.createdBy || + team?.createdByUid || + fallbackUserId + ); + + const rawMembers = Array.isArray(team?.members) ? team.members : []; + const members = Array.from( + new Set( + [...rawMembers.map((m: any) => normalizeUid(m)).filter(Boolean), ownerId].filter(Boolean) + ) + ); + + return { + id, + name: team?.name || "Team", + ownerId, + leaderId: ownerId, + members, + inviteCode: String(team?.inviteCode || ""), + logoId: team?.logoId || "rocket", + createdAt: team?.createdAt, + updatedAt: new Date().toISOString(), + }; + }; + useEffect(() => { if (!userId) { setLoading(false); @@ -38,8 +82,11 @@ export const useTeamPersistence = (userId: string | undefined) => { const unsubscribe = onSnapshot(q, (snapshot) => { const teams: TeamMetadata[] = []; - snapshot.forEach((doc) => { - teams.push({ id: doc.id, ...doc.data() } as TeamMetadata); + snapshot.forEach((docSnapshot) => { + const normalized = normalizeTeamPayload({ id: docSnapshot.id, ...docSnapshot.data() }); + if (normalized) { + teams.push(normalized); + } }); setMyTeams(teams); setLoading(false); @@ -51,37 +98,106 @@ export const useTeamPersistence = (userId: string | undefined) => { return () => unsubscribe(); }, [userId]); - const createTeamSync = async (teamId: string, name: string, leaderId: string, inviteCode: string, logoId?: string) => { + const upsertTeamSync = useCallback(async (teamPayload: any, fallbackUserId?: string) => { + const team = normalizeTeamPayload(teamPayload, fallbackUserId); + if (!team) return; + try { - await setDoc(doc(db, "teams", teamId), { - name, - leaderId, - members: [leaderId], - inviteCode, - logoId: logoId || "rocket" - }); + await setDoc(doc(db, "teams", team.id), { + name: team.name, + ownerId: team.ownerId, + leaderId: team.ownerId, + members: team.members, + inviteCode: team.inviteCode, + logoId: team.logoId || "rocket", + createdAt: team.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, { merge: true }); + + if (team.ownerId) { + await setDoc(doc(db, "users", team.ownerId), { + uid: team.ownerId, + ownedTeamIds: arrayUnion(team.id), + teamMemberships: arrayUnion(team.id), + updatedAt: new Date().toISOString(), + }, { merge: true }); + } + + for (const memberId of team.members) { + await setDoc(doc(db, "users", memberId), { + uid: memberId, + teamMemberships: arrayUnion(team.id), + updatedAt: new Date().toISOString(), + }, { merge: true }); + } } catch (error) { console.error("Error syncing team to Firestore:", error); } - }; + }, []); + + const createTeamSync = useCallback(async (teamId: string, name: string, leaderId: string, inviteCode: string, logoId?: string) => { + await upsertTeamSync({ + id: teamId, + name, + ownerId: leaderId, + leaderId, + members: [leaderId], + inviteCode, + logoId: logoId || "rocket", + }, leaderId); + }, [upsertTeamSync]); - const joinTeamSync = async (inviteCode: string, userId: string) => { + const joinTeamSync = useCallback(async (teamOrInviteCode: any, userId: string) => { try { - // Find team by invite code - const q = query(collection(db, "teams"), where("inviteCode", "==", inviteCode)); - const unsubscribe = onSnapshot(q, (snapshot) => { - snapshot.forEach(async (teamDoc) => { - await updateDoc(doc(db, "teams", teamDoc.id), { - members: arrayUnion(userId) + if (typeof teamOrInviteCode === "object" && teamOrInviteCode) { + const normalized = normalizeTeamPayload(teamOrInviteCode, userId); + if (normalized) { + await upsertTeamSync(normalized, userId); + await updateDoc(doc(db, "teams", normalized.id), { + members: arrayUnion(userId), + updatedAt: new Date().toISOString(), }); + await setDoc(doc(db, "users", userId), { + uid: userId, + teamMemberships: arrayUnion(normalized.id), + updatedAt: new Date().toISOString(), + }, { merge: true }); + return; + } + } + + const inviteCode = String(teamOrInviteCode || ""); + if (!inviteCode) return; + + // One-time read for invite code lookup. + const q = query(collection(db, "teams"), where("inviteCode", "==", inviteCode)); + const snapshot = await getDocs(q); + for (const teamDoc of snapshot.docs) { + await updateDoc(doc(db, "teams", teamDoc.id), { + members: arrayUnion(userId), + updatedAt: new Date().toISOString(), }); - }); - // Note: In a real production app, we'd use a server-side function or a one-time get - // but for this sync logic, we'll keep it simple for now. + await setDoc(doc(db, "users", userId), { + uid: userId, + teamMemberships: arrayUnion(teamDoc.id), + updatedAt: new Date().toISOString(), + }, { merge: true }); + } } catch (error) { console.error("Error joining team in Firestore:", error); } - }; + }, [upsertTeamSync]); + + const syncTeamsFromApi = useCallback(async (teams: any[], fallbackUserId?: string) => { + try { + if (!Array.isArray(teams) || teams.length === 0) return; + for (const team of teams) { + await upsertTeamSync(team, fallbackUserId); + } + } catch (error) { + console.error("Error syncing teams list to Firestore:", error); + } + }, [upsertTeamSync]); - return { myTeams, loading, createTeamSync, joinTeamSync }; + return { myTeams, loading, createTeamSync, joinTeamSync, upsertTeamSync, syncTeamsFromApi }; }; diff --git a/src/lib/SocketIOProvider.ts b/src/lib/SocketIOProvider.ts index 98b506a..d7dd442 100644 --- a/src/lib/SocketIOProvider.ts +++ b/src/lib/SocketIOProvider.ts @@ -1,7 +1,7 @@ import * as Y from 'yjs'; import { io, Socket } from 'socket.io-client'; import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate } from 'y-protocols/awareness'; -import { API_BASE_URL } from '@/lib/utils'; +import { SOCKET_BASE_URL } from '@/lib/utils'; import { Observable } from 'lib0/observable'; export class SocketIOProvider extends Observable { @@ -20,10 +20,13 @@ export class SocketIOProvider extends Observable { color: user.color || '#3b82f6', }); - const socketUrl = import.meta.env.DEV ? "http://localhost:5000" : API_BASE_URL; + const socketUrl = SOCKET_BASE_URL; this.socket = io(`${socketUrl}/notes`, { - transports: ['websocket'], + transports: ['websocket', 'polling'], + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000, }); this.socket.on('connect', () => { diff --git a/src/lib/team-logos.ts b/src/lib/team-logos.ts index eb40b64..59209cb 100644 --- a/src/lib/team-logos.ts +++ b/src/lib/team-logos.ts @@ -1,118 +1,127 @@ -import { - Rocket, Shield, Zap, Globe, Cpu, Atom, Anchor, - Binary, Boxes, Briefcase, Bug, Cloud, Code, - Compass, Container, Database, Diamond, Eye, - Feather, FileCode, Filter, Flame, FlaskConical, - Folder, Gauge, Gem, Ghost, Gift, GraduationCap, - HardDrive, Heart, Infinity, Key, Laptop, Layers, - Lightbulb, Link, Lock, Map, Megaphone, Microscope, - Music, Palette, Paperclip, Phone, PieChart, - Puzzle, Radio, Search, Send, Settings, Share2, - Smile, Star, Target, Terminal, Trophy, Users, - Video, Wallet, Wand2, Watch, Wind, Wrench -} from "lucide-react"; +import { createElement } from "react"; +import type { ComponentType, SVGProps } from "react"; -export type TeamLogoId = - | "rocket" | "shield" | "zap" | "globe" | "cpu" | "atom" | "anchor" - | "binary" | "boxes" | "briefcase" | "bug" | "cloud" | "code" - | "compass" | "container" | "database" | "diamond" | "eye" - | "feather" | "file-code" | "filter" | "flame" | "flask" - | "folder" | "gauge" | "gem" | "ghost" | "gift" | "grad" - | "hdd" | "heart" | "infinity" | "key" | "laptop" | "layers" - | "bulb" | "link" | "lock" | "map" | "megaphone" | "micro" - | "music" | "palette" | "clip" | "phone" | "pie" - | "puzzle" | "radio" | "search" | "send" | "settings" | "share" - | "smile" | "star" | "target" | "terminal" | "trophy" | "users" - | "video" | "wallet" | "wand" | "watch" | "wind" | "wrench"; +type LogoIcon = ComponentType>; + +type PathDef = { kind: "path"; d: string }; +type CircleDef = { kind: "circle"; cx: number; cy: number; r: number }; +type ShapeDef = PathDef | CircleDef; + +const buildIcon = (shapes: ShapeDef[]): LogoIcon => { + const Icon: LogoIcon = (props) => + createElement( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 1.7, + strokeLinecap: "round", + strokeLinejoin: "round", + ...props, + }, + shapes.map((shape, index) => { + if (shape.kind === "circle") { + return createElement("circle", { key: index, cx: shape.cx, cy: shape.cy, r: shape.r }); + } + return createElement("path", { key: index, d: shape.d }); + }) + ); + return Icon; +}; + +const EMBLEM_SET: LogoIcon[] = [ + buildIcon([{ kind: "path", d: "M12 2.8l2.2 4.6L19 9.6l-4.8 2.2L12 16.4l-2.2-4.6L5 9.6l4.8-2.2L12 2.8z" }, { kind: "circle", cx: 12, cy: 12, r: 1.6 }]), + buildIcon([{ kind: "path", d: "M3 15.5c4-1.2 7-4.3 9-9 2 4.7 5 7.8 9 9" }, { kind: "path", d: "M5 19c3.4-.7 5.8-2.6 7-5.7 1.2 3.1 3.6 5 7 5.7" }, { kind: "path", d: "M12 5v14" }]), + buildIcon([{ kind: "circle", cx: 12, cy: 12, r: 3.2 }, { kind: "path", d: "M4.5 12c2.1-3.3 5-5 7.5-5s5.4 1.7 7.5 5c-2.1 3.3-5 5-7.5 5s-5.4-1.7-7.5-5z" }, { kind: "path", d: "M12 4.5c3.3 2.1 5 5 5 7.5s-1.7 5.4-5 7.5c-3.3-2.1-5-5-5-7.5s1.7-5.4 5-7.5z" }]), + buildIcon([{ kind: "path", d: "M12 3l7 4.2V12c0 5-3.1 7.6-7 9-3.9-1.4-7-4-7-9V7.2L12 3z" }, { kind: "path", d: "M9 12l2 2 4-4" }]), + buildIcon([{ kind: "path", d: "M3 12c2.3-2.3 4.6-3.5 7-3.5S14.7 9.7 17 12c-2.3 2.3-4.6 3.5-7 3.5S5.3 14.3 3 12z" }, { kind: "path", d: "M18 7a9.5 9.5 0 010 10" }, { kind: "path", d: "M6 7a9.5 9.5 0 000 10" }, { kind: "circle", cx: 10, cy: 12, r: 1.2 }]), + buildIcon([{ kind: "path", d: "M12 2l2.4 4.7L20 9l-3.8 3 1.1 5-5.3-2.5L6.7 17l1.1-5L4 9l5.6-2.3L12 2z" }, { kind: "path", d: "M12 8v8" }, { kind: "path", d: "M8 12h8" }]), + buildIcon([{ kind: "path", d: "M2.5 12h4l2-3.2L11 16l2.2-4.1 1.3 2.1h7" }, { kind: "path", d: "M5 7.5A7.8 7.8 0 0112 4a7.8 7.8 0 017 3.5" }, { kind: "path", d: "M5 16.5A7.8 7.8 0 0012 20a7.8 7.8 0 007-3.5" }]), + buildIcon([{ kind: "path", d: "M12 2l7 7-7 13L5 9l7-7z" }, { kind: "path", d: "M12 2v20" }, { kind: "path", d: "M5 9h14" }]), + buildIcon([{ kind: "circle", cx: 8, cy: 9, r: 4 }, { kind: "circle", cx: 16, cy: 9, r: 4 }, { kind: "path", d: "M5.5 16.5L12 21l6.5-4.5" }]), + buildIcon([{ kind: "path", d: "M12 3v18" }, { kind: "path", d: "M7 6l2.2 4.2L7 14" }, { kind: "path", d: "M17 6l-2.2 4.2L17 14" }, { kind: "path", d: "M9 21h6" }]), + buildIcon([{ kind: "path", d: "M4 8c2-2.7 4.8-4 8-4s6 1.3 8 4" }, { kind: "path", d: "M4 16c2 2.7 4.8 4 8 4s6-1.3 8-4" }, { kind: "path", d: "M7.5 12c1.3-1.8 2.8-2.7 4.5-2.7s3.2.9 4.5 2.7c-1.3 1.8-2.8 2.7-4.5 2.7S8.8 13.8 7.5 12z" }]), + buildIcon([{ kind: "path", d: "M12 2.8l8.3 4.8v8.8L12 21.2l-8.3-4.8V7.6L12 2.8z" }, { kind: "path", d: "M12 2.8v18.4" }, { kind: "path", d: "M3.7 7.6L12 12l8.3-4.4" }]), +]; + +export type TeamLogoId = + | "rocket" | "shield" | "zap" | "globe" | "cpu" | "atom" | "anchor" + | "binary" | "boxes" | "briefcase" | "bug" | "cloud" | "code" + | "compass" | "container" | "database" | "diamond" | "eye" + | "feather" | "file-code" | "filter" | "flame" | "flask" + | "folder" | "gauge" | "gem" | "ghost" | "gift" | "grad" + | "hdd" | "heart" | "infinity" | "key" | "laptop" | "layers" + | "bulb" | "link" | "lock" | "map" | "megaphone" | "micro" + | "music" | "palette" | "clip" | "phone" | "pie" + | "puzzle" | "radio" | "search" | "send" | "settings" | "share" + | "smile" | "star" | "target" | "terminal" | "trophy" | "users" + | "video" | "wallet" | "wand" | "watch" | "wind" | "wrench"; export interface TeamLogo { - id: TeamLogoId; - icon: any; - label: string; + id: TeamLogoId; + icon: LogoIcon; + label: string; + fgColor: string; + bgColor: string; + borderColor: string; } -export const TEAM_LOGOS: TeamLogo[] = [ - { id: "rocket", icon: Rocket, label: "Rocket" }, - { id: "shield", icon: Shield, label: "Shield" }, - { id: "zap", icon: Zap, label: "Fast" }, - { id: "globe", icon: Globe, label: "Global" }, - { id: "cpu", icon: Cpu, label: "Tech" }, - { id: "atom", icon: Atom, label: "Science" }, - { id: "anchor", icon: Anchor, label: "Stable" }, - { id: "binary", icon: Binary, label: "Binary" }, - { id: "boxes", icon: Boxes, label: "Storage" }, - { id: "briefcase", icon: Briefcase, label: "Business" }, - { id: "bug", icon: Bug, label: "QA" }, - { id: "cloud", icon: Cloud, label: "Cloud" }, - { id: "code", icon: Code, label: "Code" }, - { id: "compass", icon: Compass, label: "Navigate" }, - { id: "container", icon: Container, label: "Docker" }, - { id: "database", icon: Database, label: "Data" }, - { id: "diamond", icon: Diamond, label: "Premium" }, - { id: "eye", icon: Eye, label: "Vision" }, - { id: "feather", icon: Feather, label: "Light" }, - { id: "file-code", icon: FileCode, label: "Script" }, - { id: "filter", icon: Filter, label: "Filter" }, - { id: "flame", icon: Flame, label: "Hot" }, - { id: "flask", icon: FlaskConical, label: "Lab" }, - { id: "folder", icon: Folder, label: "Assets" }, - { id: "gauge", icon: Gauge, label: "Performance" }, - { id: "gem", icon: Gem, label: "Jewel" }, - { id: "ghost", icon: Ghost, label: "Stealth" }, - { id: "gift", icon: Gift, label: "Rewards" }, - { id: "grad", icon: GraduationCap, label: "Learn" }, - { id: "hdd", icon: HardDrive, label: "Server" }, - { id: "heart", icon: Heart, label: "Care" }, - { id: "infinity", icon: Infinity, label: "Endless" }, - { id: "key", icon: Key, label: "Security" }, - { id: "laptop", icon: Laptop, label: "Hardware" }, - { id: "layers", icon: Layers, label: "Stack" }, - { id: "bulb", icon: Lightbulb, label: "Idea" }, - { id: "link", icon: Link, label: "Network" }, - { id: "lock", icon: Lock, label: "Vault" }, - { id: "map", icon: Map, label: "Route" }, - { id: "megaphone", icon: Megaphone, label: "Alert" }, - { id: "micro", icon: Microscope, label: "Research" }, - { id: "music", icon: Music, label: "Entertainment" }, - { id: "palette", icon: Palette, label: "Art" }, - { id: "clip", icon: Paperclip, label: "Attachment" }, - { id: "phone", icon: Phone, label: "Support" }, - { id: "pie", icon: PieChart, label: "Stats" }, - { id: "puzzle", icon: Puzzle, label: "Logic" }, - { id: "radio", icon: Radio, label: "Signal" }, - { id: "search", icon: Search, label: "Find" }, - { id: "send", icon: Send, label: "Launch" }, - { id: "settings", icon: Settings, label: "Config" }, - { id: "share", icon: Share2, label: "Share" }, - { id: "smile", icon: Smile, label: "Social" }, - { id: "star", icon: Star, label: "Favorite" }, - { id: "target", icon: Target, label: "Goal" }, - { id: "terminal", icon: Terminal, label: "Console" }, - { id: "trophy", icon: Trophy, label: "Win" }, - { id: "users", icon: Users, label: "Social" }, - { id: "video", icon: Video, label: "Media" }, - { id: "wallet", icon: Wallet, label: "Finance" }, - { id: "wand", icon: Wand2, label: "Magic" }, - { id: "watch", icon: Watch, label: "Time" }, - { id: "wind", icon: Wind, label: "Air" }, - { id: "wrench", icon: Wrench, label: "Tools" } +const LEGACY_LOGO_IDS: TeamLogoId[] = [ + "rocket", "shield", "zap", "globe", "cpu", "atom", "anchor", + "binary", "boxes", "briefcase", "bug", "cloud", "code", + "compass", "container", "database", "diamond", "eye", + "feather", "file-code", "filter", "flame", "flask", + "folder", "gauge", "gem", "ghost", "gift", "grad", + "hdd", "heart", "infinity", "key", "laptop", "layers", + "bulb", "link", "lock", "map", "megaphone", "micro", + "music", "palette", "clip", "phone", "pie", + "puzzle", "radio", "search", "send", "settings", "share", + "smile", "star", "target", "terminal", "trophy", "users", + "video", "wallet", "wand", "watch", "wind", "wrench", ]; +const toLabel = (id: TeamLogoId): string => + id.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" "); + +const LOGO_PALETTES = [ + { fg: "#7C3AED", bg: "#EDE9FE", border: "#C4B5FD" }, + { fg: "#0891B2", bg: "#CFFAFE", border: "#67E8F9" }, + { fg: "#2563EB", bg: "#DBEAFE", border: "#93C5FD" }, + { fg: "#DB2777", bg: "#FCE7F3", border: "#F9A8D4" }, + { fg: "#DC2626", bg: "#FEE2E2", border: "#FCA5A5" }, + { fg: "#7C3AED", bg: "#E9D5FF", border: "#C4B5FD" }, + { fg: "#EA580C", bg: "#FFEDD5", border: "#FDBA74" }, + { fg: "#84CC16", bg: "#ECFCCB", border: "#BEF264" }, + { fg: "#0EA5E9", bg: "#E0F2FE", border: "#7DD3FC" }, + { fg: "#0F766E", bg: "#CCFBF1", border: "#5EEAD4" }, + { fg: "#4F46E5", bg: "#E0E7FF", border: "#A5B4FC" }, + { fg: "#D97706", bg: "#FEF3C7", border: "#FCD34D" }, +] as const; + +export const TEAM_LOGOS: TeamLogo[] = LEGACY_LOGO_IDS.map((id, index) => ({ + id, + icon: EMBLEM_SET[index % EMBLEM_SET.length], + label: toLabel(id), + fgColor: LOGO_PALETTES[index % LOGO_PALETTES.length].fg, + bgColor: LOGO_PALETTES[index % LOGO_PALETTES.length].bg, + borderColor: LOGO_PALETTES[index % LOGO_PALETTES.length].border, +})); + export const getLogoById = (id: string): TeamLogo => { - return TEAM_LOGOS.find(l => l.id === id) || TEAM_LOGOS[0]; + return TEAM_LOGOS.find((logo) => logo.id === id) || TEAM_LOGOS[0]; }; export const getRandomLogoId = (): TeamLogoId => { - const randomIndex = Math.floor(Math.random() * TEAM_LOGOS.length); - return TEAM_LOGOS[randomIndex].id; + const randomIndex = Math.floor(Math.random() * TEAM_LOGOS.length); + return TEAM_LOGOS[randomIndex].id; }; -// Deterministic logo for consistent assignment to existing teams export const getDeterministicLogoId = (seed: string): TeamLogoId => { - let hash = 0; - for (let i = 0; i < seed.length; i++) { - hash = seed.charCodeAt(i) + ((hash << 5) - hash); - } - const index = Math.abs(hash % TEAM_LOGOS.length); - return TEAM_LOGOS[index].id; + let hash = 0; + for (let i = 0; i < seed.length; i++) { + hash = seed.charCodeAt(i) + ((hash << 5) - hash); + } + const index = Math.abs(hash % TEAM_LOGOS.length); + return TEAM_LOGOS[index].id; }; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 0a54999..20f8745 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -2,6 +2,10 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export const API_BASE_URL = import.meta.env.DEV ? "" : (import.meta.env.VITE_API_URL || ""); +export const SOCKET_BASE_URL = + import.meta.env.VITE_SOCKET_URL || + import.meta.env.VITE_API_URL || + (import.meta.env.DEV ? "http://localhost:5000" : ""); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); diff --git a/src/pages/ProjectDetails.tsx b/src/pages/ProjectDetails.tsx index 44836ae..f7652c6 100644 --- a/src/pages/ProjectDetails.tsx +++ b/src/pages/ProjectDetails.tsx @@ -10,7 +10,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ArrowLeft, CheckCircle2, Circle, Server, Layout, Database, Share2, Plus, GripVertical, GitCommit, ExternalLink, Kanban, Trash2, Github, Bot, MoreVertical, Settings, MessageSquare, Wrench, FolderKanban } from "lucide-react"; -import { API_BASE_URL, getFullUrl } from "@/lib/utils"; +import { API_BASE_URL, SOCKET_BASE_URL, getFullUrl } from "@/lib/utils"; import { auth } from "@/lib/firebase"; import { sendMessage as socketSendMessage } from "@/services/chatSocketService"; import { useTaskUpdates } from "@/hooks/use-task-updates"; @@ -267,7 +267,12 @@ const ProjectDetails = () => { useEffect(() => { - const socket = io(API_BASE_URL); + const socket = io(SOCKET_BASE_URL || API_BASE_URL, { + transports: ["websocket", "polling"], + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000, + }); socket.on('connect', () => { console.log('Connected to WebSocket'); diff --git a/src/services/chatSocketService.ts b/src/services/chatSocketService.ts index 6716512..d039bc5 100644 --- a/src/services/chatSocketService.ts +++ b/src/services/chatSocketService.ts @@ -1,5 +1,5 @@ import { io, Socket } from 'socket.io-client'; -import { API_BASE_URL } from '@/lib/utils'; +import { SOCKET_BASE_URL } from '@/lib/utils'; export interface ChatMessage { id: string; @@ -43,13 +43,13 @@ const typingListeners = new Set(); export function connectChat(userId: string): Socket { if (socket?.connected) {return socket;} - const socketUrl = import.meta.env.DEV ? 'http://localhost:5000' : API_BASE_URL; + const socketUrl = SOCKET_BASE_URL; socket = io(`${socketUrl}/chat`, { query: { userId }, - transports: ['websocket'], + transports: ['websocket', 'polling'], reconnection: true, - reconnectionAttempts: Infinity, + reconnectionAttempts: 5, reconnectionDelay: 1000, }); diff --git a/src/services/taskSocketService.ts b/src/services/taskSocketService.ts index 51dc53c..cbf2a6f 100644 --- a/src/services/taskSocketService.ts +++ b/src/services/taskSocketService.ts @@ -1,5 +1,5 @@ import { io, Socket } from 'socket.io-client'; -import { API_BASE_URL } from '@/lib/utils'; +import { SOCKET_BASE_URL } from '@/lib/utils'; export interface TaskEvent { projectId: string; @@ -28,13 +28,13 @@ const joinedProjects = new Set(); export function connectTaskSocket(userId: string): Socket { if (socket?.connected) return socket; - const socketUrl = import.meta.env.DEV ? 'http://localhost:5000' : API_BASE_URL; + const socketUrl = SOCKET_BASE_URL; socket = io(`${socketUrl}/tasks`, { query: { userId }, - transports: ['websocket'], + transports: ['websocket', 'polling'], reconnection: true, - reconnectionAttempts: Infinity, + reconnectionAttempts: 5, reconnectionDelay: 1000, });