From 128c96df35dc0c931cffa09e71aec069a5b8c58c Mon Sep 17 00:00:00 2001 From: mohamed mahmoud Date: Fri, 4 Sep 2026 21:48:52 +0300 Subject: [PATCH 1/2] feat: add native job opportunity feed rendering and creation --- src/domain/entity/TaskEntity.js | 3 + src/domain/mapper/EntityMapper.js | 9 +- src/domain/useCase/task/useKanbanBoard.js | 44 ++- src/domain/useCase/useFetchBlogs.js | 8 +- src/infrastructure/DTO/BlogDTO.js | 3 + src/infrastructure/DTO/TaskDTO.js | 3 + .../repository/ProjectRepository.js | 17 ++ .../feature/feed/components/FeedItem.jsx | 26 +- .../feature/feed/routes/FeedPage.jsx | 9 - .../project/components/KanbanBoard.jsx | 283 +++++++++++++----- .../project/components/MissionsRepo.jsx | 150 +++++++++- .../project/components/ProjectMembers.jsx | 188 +++++++++--- .../project/routes/ProjectDashboard.jsx | 82 +++-- .../shared/components/layout/Header.jsx | 22 +- .../shared/components/layout/Sidebar.jsx | 6 +- 15 files changed, 694 insertions(+), 159 deletions(-) diff --git a/src/domain/entity/TaskEntity.js b/src/domain/entity/TaskEntity.js index a6c8d6d..e2d4e3d 100644 --- a/src/domain/entity/TaskEntity.js +++ b/src/domain/entity/TaskEntity.js @@ -9,6 +9,9 @@ export class TaskEntity { this.branchPattern = data.branchPattern; this.githubPrId = data.githubPrId; this.ciStatus = data.ciStatus; + this.lastCommitSha = data.lastCommitSha; // latest commit SHA (short) + this.prUrl = data.prUrl; // GitHub PR URL + this.prTitle = data.prTitle; // GitHub PR title this.order = data.order; this.createdAt = data.createdAt; this.updatedAt = data.updatedAt; diff --git a/src/domain/mapper/EntityMapper.js b/src/domain/mapper/EntityMapper.js index 9079fe0..3908649 100644 --- a/src/domain/mapper/EntityMapper.js +++ b/src/domain/mapper/EntityMapper.js @@ -436,7 +436,10 @@ export class EntityMapper { description: dto.description, image: dto.image ? this.toMedia(dto.image) : null, author: dto.publisher ? this.toUser(dto.publisher) : null, - isDraft: !!dto.isDraft + isDraft: !!dto.isDraft, + type: dto.type || 'blog', + projectRole: dto.project_role ? this.toProjectRole(dto.project_role) : null, + project: dto.project ? this.toProject(dto.project) : null }); } @@ -739,6 +742,9 @@ export class EntityMapper { branchPattern: dto.branch_pattern, githubPrId: dto.github_pr_id, ciStatus: dto.ci_status, + lastCommitSha: dto.last_commit_sha, + prUrl: dto.pr_url, + prTitle: dto.pr_title, order: dto.order, createdAt: dto.createdAt, updatedAt: dto.updatedAt, @@ -753,6 +759,7 @@ export class EntityMapper { }); } + /** * Maps a CheckpointDTO to CheckpointEntity */ diff --git a/src/domain/useCase/task/useKanbanBoard.js b/src/domain/useCase/task/useKanbanBoard.js index ce1e31c..3c422ff 100644 --- a/src/domain/useCase/task/useKanbanBoard.js +++ b/src/domain/useCase/task/useKanbanBoard.js @@ -1,21 +1,47 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useRef } from 'react'; import { useAsyncUseCase } from '../useAsyncUseCase'; import { repositoryRegistry } from '@infrastructure/repository/RepositoryRegistry'; import { EntityMapper } from '../../mapper/EntityMapper'; +// Auto-refresh interval in milliseconds (15s when tab is visible) +const REFRESH_INTERVAL_MS = 15000; + export const useKanbanBoard = (projectId) => { const repository = useMemo(() => repositoryRegistry.taskRepository, []); - // Fetch tasks + // ─── Fetch tasks ───────────────────────────────────────────────────────── const fetchLogic = useCallback(async () => { if (!projectId) return []; const result = await repository.getTasks(projectId); return result.map(dto => EntityMapper.toTask(dto)); }, [repository, projectId]); - const { execute: fetchTasks, returnedData: tasks, inProgress: loadingTasks, error: tasksError } = useAsyncUseCase(fetchLogic); + const { + execute: fetchTasks, + returnedData: tasks, + inProgress: loadingTasks, + error: tasksError, + } = useAsyncUseCase(fetchLogic); + + // ─── Silent refresh (no loading spinner) ───────────────────────────────── + // Returns a stable ref so the polling interval can always call the latest version + const fetchTasksRef = useRef(fetchTasks); + fetchTasksRef.current = fetchTasks; + + const startPolling = useCallback(() => { + if (!projectId) return () => {}; + + const id = setInterval(() => { + // Only refresh when the browser tab is visible to avoid wasted requests + if (document.visibilityState === 'visible') { + fetchTasksRef.current?.(); + } + }, REFRESH_INTERVAL_MS); + + return () => clearInterval(id); + }, [projectId]); - // Transition Task Status + // ─── Transition Task Status ─────────────────────────────────────────────── const transitionLogic = useCallback(async (taskId, newStatus) => { const result = await repository.transitionTask(projectId, taskId, newStatus); return EntityMapper.toTask(result); @@ -23,15 +49,14 @@ export const useKanbanBoard = (projectId) => { const { execute: transitionTask, inProgress: isTransitioning } = useAsyncUseCase(transitionLogic); - // Reorder Tasks + // ─── Reorder Tasks ──────────────────────────────────────────────────────── const reorderLogic = useCallback(async (taskOrders) => { - // taskOrders = [{ documentId, order }] return await repository.reorderTasks(projectId, taskOrders); }, [repository, projectId]); const { execute: reorderTasks, inProgress: isReordering } = useAsyncUseCase(reorderLogic); - // Create Task + // ─── Create Task ────────────────────────────────────────────────────────── const createLogic = useCallback(async (taskData) => { const result = await repository.createTask(projectId, taskData); return EntityMapper.toTask(result); @@ -39,7 +64,7 @@ export const useKanbanBoard = (projectId) => { const { execute: createTask, inProgress: isCreatingTask } = useAsyncUseCase(createLogic); - // Delete Task + // ─── Delete Task ────────────────────────────────────────────────────────── const deleteLogic = useCallback(async (taskId) => { return await repository.deleteTask(projectId, taskId); }, [repository, projectId]); @@ -51,6 +76,7 @@ export const useKanbanBoard = (projectId) => { tasks, loadingTasks, tasksError, + startPolling, // call this in KanbanBoard's useEffect → returns cleanup fn transitionTask, isTransitioning, reorderTasks, @@ -58,6 +84,6 @@ export const useKanbanBoard = (projectId) => { createTask, isCreatingTask, deleteTask, - isDeletingTask + isDeletingTask, }; }; diff --git a/src/domain/useCase/useFetchBlogs.js b/src/domain/useCase/useFetchBlogs.js index e4da6e6..f5f487d 100644 --- a/src/domain/useCase/useFetchBlogs.js +++ b/src/domain/useCase/useFetchBlogs.js @@ -33,7 +33,13 @@ export const useFetchBlogs = () => { fields: ['firstname', 'lastname', 'email', 'username', 'birthday', 'university', 'bio'], populate: ['avatar'] }, - image: true + image: true, + project_role: { + populate: ['job_title_tag'] + }, + project: { + populate: ['publisher'] + } }; const rawData = await repository.getBlogs(limit, excludeIds, feedType, populate); diff --git a/src/infrastructure/DTO/BlogDTO.js b/src/infrastructure/DTO/BlogDTO.js index 8d373e3..b7df9b6 100644 --- a/src/infrastructure/DTO/BlogDTO.js +++ b/src/infrastructure/DTO/BlogDTO.js @@ -14,5 +14,8 @@ export class BlogDTO extends BaseContentDTO { * Relationships */ this.publisher = data.publisher ? new UserDTO(data.publisher) : null; // {UserDTO | null} + this.type = data.type || 'blog'; + this.project_role = data.project_role || null; + this.project = data.project || null; } } diff --git a/src/infrastructure/DTO/TaskDTO.js b/src/infrastructure/DTO/TaskDTO.js index e4c40a8..a9aa53f 100644 --- a/src/infrastructure/DTO/TaskDTO.js +++ b/src/infrastructure/DTO/TaskDTO.js @@ -11,6 +11,9 @@ export class TaskDTO { this.branch_pattern = data.branch_pattern; this.github_pr_id = data.github_pr_id; this.ci_status = data.ci_status; // pending, success, failure, cancelled + this.last_commit_sha = data.last_commit_sha; + this.pr_url = data.pr_url; + this.pr_title = data.pr_title; this.order = data.order; this.createdAt = data.createdAt; this.updatedAt = data.updatedAt; diff --git a/src/infrastructure/repository/ProjectRepository.js b/src/infrastructure/repository/ProjectRepository.js index 1ba161f..0ef3561 100644 --- a/src/infrastructure/repository/ProjectRepository.js +++ b/src/infrastructure/repository/ProjectRepository.js @@ -71,6 +71,23 @@ export class ProjectRepository extends IProjectAccess { return response?.data || response; } + async updateMemberGithubUsername(projectId, memberId, githubUsername) { + const response = await this.apiClient.patch( + `${this.projectsEndpoint}/${projectId}/members/${memberId}/github-username`, + { github_username: githubUsername } + ); + return new ProjectMemberDTO(response?.data || response); + } + + // --- GitHub Repo Linking --- + async linkGithubRepo(projectId, { github_repo_url, github_repo_id, github_webhook_secret }) { + const response = await this.apiClient.patch( + `${this.projectsEndpoint}/${projectId}/github-repo`, + { github_repo_url, github_repo_id, github_webhook_secret } + ); + return response?.data || response; + } + // --- Applications --- async applyToRole(projectId, data) { const response = await this.apiClient.post(`${this.projectsEndpoint}/${projectId}/apply`, data); diff --git a/src/presentation/feature/feed/components/FeedItem.jsx b/src/presentation/feature/feed/components/FeedItem.jsx index 5dfe969..c8a1cb1 100644 --- a/src/presentation/feature/feed/components/FeedItem.jsx +++ b/src/presentation/feature/feed/components/FeedItem.jsx @@ -55,6 +55,8 @@ export const FeedItem = ({ blog, className, rank, highlight }) => { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }; + const isJobOpportunity = blog.type === 'job_opportunity'; + // Determine Podium Styles let podiumStyles = "border-border-subtle bg-surface-dark"; let badge = null; @@ -71,7 +73,17 @@ export const FeedItem = ({ blog, className, rank, highlight }) => { } else if (highlight) { podiumStyles = "border-accent-primary/50 bg-accent-primary/5 shadow-[0_0_40px_rgba(var(--accent-primary-rgb),0.1)] ring-1 ring-accent-primary/20"; } - console.log(blog); + + if (isJobOpportunity) { + podiumStyles = "border-blue-500/50 bg-gradient-to-br from-blue-900/10 to-blue-500/5 shadow-[0_0_40px_rgba(59,130,246,0.15)] ring-1 ring-blue-500/30"; + badge = ( +
+ 🔥 Project Opportunity +
+ ); + } + + // console.log(blog); return (
{ )}> {badge} {/* Header: Author & Meta */} -
+
{ if (blog.author?.username) { @@ -188,6 +200,16 @@ export const FeedItem = ({ blog, className, rank, highlight }) => { />
)} + + {isJobOpportunity && blog.project && ( +
+ + + +
+ )}
{/* Interaction Footer */} diff --git a/src/presentation/feature/feed/routes/FeedPage.jsx b/src/presentation/feature/feed/routes/FeedPage.jsx index 0e96ddf..8f84d5e 100644 --- a/src/presentation/feature/feed/routes/FeedPage.jsx +++ b/src/presentation/feature/feed/routes/FeedPage.jsx @@ -144,21 +144,12 @@ const FeedPage = () => { {/* 2. Show regular feed, filtering out the linked blog to avoid duplication */} {(blogs || []).filter(b => b.id !== linkedBlog?.id).map((blog, index) => { - const showJobAd = (index > 0 && index % 10 === 0); - const jobAdIndex = (index / 10) - 1; - const jobAdToRender = showJobAd && jobAds ? jobAds[jobAdIndex] : null; - return ( - {jobAdToRender && ( -
- -
- )}
); })} diff --git a/src/presentation/feature/project/components/KanbanBoard.jsx b/src/presentation/feature/project/components/KanbanBoard.jsx index 25f2bf8..2e92b62 100644 --- a/src/presentation/feature/project/components/KanbanBoard.jsx +++ b/src/presentation/feature/project/components/KanbanBoard.jsx @@ -1,9 +1,9 @@ -import React, { useEffect, useState } from 'react'; -import { Card, Typography, Spin, Alert, Tag, Button, Dropdown, Space, Modal, Form, Input, Select, DatePicker } from 'antd'; -import { MoreOutlined, GithubOutlined, CheckCircleOutlined, SyncOutlined, StopOutlined } from '@ant-design/icons'; +import React, { useEffect, useState, useCallback } from 'react'; +import { Card, Typography, Spin, Tag, Button, Dropdown, Modal, Form, Input, Select, DatePicker, Tooltip } from 'antd'; +import { MoreOutlined, GithubOutlined, CheckCircleOutlined, SyncOutlined, StopOutlined, CopyOutlined, LinkOutlined } from '@ant-design/icons'; import { useKanbanBoard } from '@domain/useCase/task/useKanbanBoard'; import { useSprints } from '@domain/useCase/task/useSprints'; -import { Plus, Trash2, Calendar, Flame, AlertCircle, Play, CheckSquare, Layers, Award, Target, HelpCircle } from 'lucide-react'; +import { Plus, Trash2, Calendar, Flame, AlertCircle, Play, CheckSquare, Layers, Target, GitBranch, GitPullRequest, GitCommit, BookOpen, ChevronDown, ChevronUp } from 'lucide-react'; import { toast } from 'react-hot-toast'; const { Title, Text, Paragraph } = Typography; @@ -29,7 +29,8 @@ export const KanbanBoard = ({ project }) => { // Usecases const { - fetchTasks, + fetchTasks, + startPolling, tasks, loadingTasks, tasksError, @@ -62,11 +63,13 @@ export const KanbanBoard = ({ project }) => { const [taskForm] = Form.useForm(); useEffect(() => { - if (projectId) { - fetchTasks(); - fetchSprints(); - } - }, [projectId, fetchTasks, fetchSprints]); + if (!projectId) return; + fetchTasks(); + fetchSprints(); + // Start silent background polling — returns cleanup fn + const stopPolling = startPolling(); + return () => stopPolling(); + }, [projectId]); // eslint-disable-line react-hooks/exhaustive-deps // Derived values const activeSprint = sprints?.find(s => s.status === 'active'); @@ -602,113 +605,105 @@ export const KanbanBoard = ({ project }) => { ); }; -const TaskCard = ({ task, onTransition, onDelete, isTransitioning, projectMembers, project, showSprintTag }) => { - const items = STATUS_COLUMNS - .filter(c => c.key !== task.status) - .map(c => ({ - key: c.key, - label: `Move to ${c.title}`, - onClick: () => onTransition(c.key) - })); - - // Add Delete item with separator - items.push({ - type: 'divider' - }); - - items.push({ - key: 'delete', - label: Delete Task, - onClick: onDelete - }); +// ───────────────────────────────────────────────────────────────────────────── +// TaskCard — shows task info + live GitHub state + Developer Guide panel +// ───────────────────────────────────────────────────────────────────────────── +const TaskCard = ({ task, onTransition, onDelete, isTransitioning, project, showSprintTag }) => { + const [guideOpen, setGuideOpen] = useState(false); + + const menuItems = [ + ...STATUS_COLUMNS + .filter(c => c.key !== task.status) + .map(c => ({ key: c.key, label: `Move to ${c.title}`, onClick: () => onTransition(c.key) })), + { type: 'divider' }, + { + key: 'guide', + label: Developer Guide, + onClick: () => setGuideOpen(v => !v) + }, + { type: 'divider' }, + { + key: 'delete', + label: Delete Task, + onClick: onDelete + }, + ]; + + // ── Suggested branch name ────────────────────────────────────────────── + const suggestedBranch = task.branchPattern + ? task.branchPattern + : `task/${task.uid}`; + + const copyToClipboard = (text, label) => { + navigator.clipboard.writeText(text).then(() => toast.success(`${label} copied!`)); + }; - // Resolve target layer label + // ── Layer label ──────────────────────────────────────────────────────── const targetLayerNode = (project?.architectureDiagram?.nodes || []).find(n => n.id === task.layerId); let layerLabel = null; if (targetLayerNode) { layerLabel = targetLayerNode.data?.label || 'Unnamed Module'; - if (layerLabel === 'New Concept' && targetLayerNode.data?.content && targetLayerNode.data.content.length > 0) { - const textBlock = targetLayerNode.data.content.find(b => b.type === 'paragraph' || b.type === 'header'); - if (textBlock && textBlock.data?.text) { - layerLabel = textBlock.data.text.replace(/<[^>]*>/g, ''); - } + if (layerLabel === 'New Concept' && targetLayerNode.data?.content?.length > 0) { + const tb = targetLayerNode.data.content.find(b => b.type === 'paragraph' || b.type === 'header'); + if (tb?.data?.text) layerLabel = tb.data.text.replace(/<[^>]*>/g, ''); } } - const typeLabels = { - general: 'General', - document: 'Doc', - flowchart: 'Flowchart', - code_commit: 'Code', - test_case: 'Test', - deployment: 'Deploy' - }; - const typeColors = { general: 'bg-white/5 text-text-muted', document: 'bg-blue-500/10 text-blue-400', flowchart: 'bg-purple-500/10 text-purple-400', code_commit: 'bg-orange-500/10 text-orange-400', test_case: 'bg-green-500/10 text-green-400', - deployment: 'bg-pink-500/10 text-pink-400' + deployment: 'bg-pink-500/10 text-pink-400', }; + const typeLabels = { general:'General', document:'Doc', flowchart:'Flowchart', code_commit:'Code', test_case:'Test', deployment:'Deploy' }; + + const hasGithubState = task.lastCommitSha || task.githubPrId || task.branchPattern; return ( - + {/* ── Header ─────────────────────────────────────────────────── */}
- + {task.title} - - - + +
- + {task.description && ( - +

{task.description} - +

)} + {/* ── Meta Tags ──────────────────────────────────────────────── */}
{task.priority} - {task.taskType && task.taskType !== 'general' && ( {typeLabels[task.taskType]} )} - {task.stage && ( {task.stage === 'implementation' ? 'Coding' : task.stage} )} - {layerLabel && ( - + {layerLabel} )} - - {task.branchPattern && ( - - {task.branchPattern} - - )} - - {task.ciStatus === 'success' && Pass} - {task.ciStatus === 'failure' && Fail} - {task.ciStatus === 'pending' && CI} - {showSprintTag && task.sprint && ( S{task.sprint.number} @@ -716,10 +711,154 @@ const TaskCard = ({ task, onTransition, onDelete, isTransitioning, projectMember )}
+ {/* ── Live GitHub State ───────────────────────────────────────── */} + {hasGithubState && ( +
+ {/* Branch */} + {(task.branchPattern || task.uid) && ( +
+ + {suggestedBranch} + + + +
+ )} + + {/* PR Link */} + {task.githubPrId && ( +
+ + PR #{task.githubPrId} + {task.prTitle && ( + · {task.prTitle} + )} + {task.prUrl && ( + + + + )} +
+ )} + + {/* Last Commit SHA */} + {task.lastCommitSha && ( +
+ + {task.lastCommitSha.substring(0, 7)} +
+ )} + + {/* CI Status */} +
+ {task.ciStatus === 'success' && ( + + CI Passing + + )} + {task.ciStatus === 'failure' && ( + + CI Failed + + )} + {task.ciStatus === 'pending' && ( + + Running + + )} + {task.ciStatus === 'cancelled' && ( + + Cancelled + + )} +
+
+ )} + + {/* ── Developer Guide (collapsible) ───────────────────────────── */} + {guideOpen && ( +
+
+ + Developer Guide +
+ + {/* Step 1 */} +
+ ① Create branch with this exact name +
+ + {suggestedBranch} + +
+
+ + {/* Step 2 */} +
+ ② OR reference in commit message +
+ + git commit -m "fix: ... TASK-{task.uid}" + +
+
+ + {/* Step 3 */} +
+ ③ Open a Pull Request +

+ When you open a PR from this branch, the task moves to In Review. + When merged, it becomes Done ✅. +

+
+ + {/* Task ID copy */} +
+ Task ID: {task.uid} + +
+
+ )} + + {/* ── Guide toggle button ─────────────────────────────────────── */} + + + {/* ── Assignee ────────────────────────────────────────────────── */} {task.assignee && ( -
- Assignee: - +
+ Assignee: + {task.assignee.user?.username}
diff --git a/src/presentation/feature/project/components/MissionsRepo.jsx b/src/presentation/feature/project/components/MissionsRepo.jsx index 795db8a..6fecfab 100644 --- a/src/presentation/feature/project/components/MissionsRepo.jsx +++ b/src/presentation/feature/project/components/MissionsRepo.jsx @@ -9,7 +9,8 @@ import { useRole } from '@core/hooks/useRole'; import { Target, ChevronRight, ChevronDown, CheckCircle2, Circle, Clock, FileText, GitBranch, Play, Rocket, Compass, Layers, Plus, Search, - X, ExternalLink, User, AlertCircle, Filter, Loader2, Calendar, Check, Send + X, ExternalLink, User, AlertCircle, Filter, Loader2, Calendar, Check, Send, + Github, GitCommit, Copy } from 'lucide-react'; import { cn } from '@core/utils/cn'; @@ -977,6 +978,153 @@ const TaskPreviewContent = ({
)} + {/* Developer Guide */} +
+
+
+ + GitHub Integration Guide +
+
+ To link your commits to this task, use one of the following methods: +
+ +
+ ① Create a branch (Recommended) +
+ + + {task.branchPattern || `task/${task.uid}`} + + +
+
+ +
+ ② OR reference in commit message +
+ + + git commit -m "fix: ... TASK-{task.uid}" + + +
+
+ +
+ Task ID: {task.uid} + +
+
+
+ + {/* GitHub Activity — Live Status */} +
+
+
+ + GitHub Activity +
+ + {!(task.lastCommitSha || task.githubPrId || task.branchPattern || task.ciStatus) ? ( +
+ + No activity yet. Follow the Developer Guide below to link your commits. +
+ ) : ( + <> + {/* Branch */} + {(task.branchPattern || task.uid) && ( +
+ + + {task.branchPattern || `task/${task.uid}`} + +
+ )} + + {/* Last Commit */} + {task.lastCommitSha && ( +
+ + + Last commit: {task.lastCommitSha.substring(0, 7)} + +
+ )} + + {/* Pull Request */} + {task.githubPrId && ( +
+ + PR #{task.githubPrId} + {task.prTitle && ( + · {task.prTitle} + )} + {task.prUrl && ( + + + + )} +
+ )} + + {/* CI Status */} + {task.ciStatus && ( +
+ {task.ciStatus === 'success' && ( + + CI Passing + + )} + {task.ciStatus === 'failure' && ( + + CI Failed + + )} + {task.ciStatus === 'pending' && ( + + Running + + )} + {task.ciStatus === 'cancelled' && ( + + Cancelled + + )} +
+ )} + + )} +
+
+ {/* Review and Deliverable Actions */}
{task.status === 'in_review' ? ( diff --git a/src/presentation/feature/project/components/ProjectMembers.jsx b/src/presentation/feature/project/components/ProjectMembers.jsx index c6faa92..417f54b 100644 --- a/src/presentation/feature/project/components/ProjectMembers.jsx +++ b/src/presentation/feature/project/components/ProjectMembers.jsx @@ -1,13 +1,15 @@ import React, { useState, useEffect } from 'react'; -import { Users, Link2, Shield, Loader2, Check, X, Clock } from 'lucide-react'; -import { Modal, Form, Select, Button, Input, Checkbox } from 'antd'; +import { Users, Link2, Shield, Loader2, Check, X, Clock, Github, Edit2 } from 'lucide-react'; +import { Modal, Form, Select, Button, Input, Checkbox, Tooltip } from 'antd'; import { toast } from 'react-hot-toast'; import { useProjectMembers } from '@domain/useCase/project/useProjectMembers'; import { useJobTitleTags } from '@domain/useCase/project/useJobTitleTags'; import { useProjectApplications } from '@domain/useCase/project/useProjectApplications'; import { useProjectRole } from '@core/hooks/useProjectRole'; +import { useRole } from '@core/hooks/useRole'; import { cn } from '@core/utils/cn'; +import { repositoryRegistry } from '@infrastructure/repository/RepositoryRegistry'; const { Option } = Select; @@ -21,6 +23,10 @@ export const ProjectMembers = ({ project, canManageMembers, projectApplication, const [isCreateRoleModalOpen, setIsCreateRoleModalOpen] = useState(false); const [createRoleForm] = Form.useForm(); + // GitHub Username editing state (memberId → { editing, value, saving }) + const [githubEditing, setGithubEditing] = useState({}); + + const { user } = useRole(); const { isMember, isPublisher } = useProjectRole(project); const { @@ -88,7 +94,8 @@ export const ProjectMembers = ({ project, canManageMembers, projectApplication, custom_label: values.customLabel, job_title_tag: values.job_title_tag, permissions: permissions, - project: project.id || project.documentId // link to project + project: project.id || project.documentId, // link to project + announce_in_feed: !!values.announce_in_feed }; const success = await createRole(payload); @@ -100,6 +107,40 @@ export const ProjectMembers = ({ project, canManageMembers, projectApplication, const pendingApplications = (applications || []).filter(app => app.status === 'pending'); + // ── GitHub Username helpers ────────────────────────────────────────────── + const startGithubEdit = (member) => { + const memberId = member.documentId || member.uid; + setGithubEditing(prev => ({ + ...prev, + [memberId]: { editing: true, value: member.githubUsername || member.github_username || '', saving: false }, + })); + }; + + const cancelGithubEdit = (memberId) => { + setGithubEditing(prev => { const n = { ...prev }; delete n[memberId]; return n; }); + }; + + const saveGithubUsername = async (member) => { + const memberId = member.documentId || member.uid; + const state = githubEditing[memberId]; + if (!state) return; + setGithubEditing(prev => ({ ...prev, [memberId]: { ...state, saving: true } })); + try { + await repositoryRegistry.projectRepository.updateMemberGithubUsername( + project.uid, + memberId, + state.value + ); + toast.success(`GitHub username saved for @${member.user?.username}`); + cancelGithubEdit(memberId); + if (onRefresh) onRefresh(); + } catch (err) { + toast.error(err.message || 'Failed to save GitHub username'); + setGithubEditing(prev => ({ ...prev, [memberId]: { ...state, saving: false } })); + } + }; + + return (
{/* Incoming Applications / Join Requests Section */} @@ -241,41 +282,110 @@ export const ProjectMembers = ({ project, canManageMembers, projectApplication,
- {(project.members || []).map((member, i) => ( -
-
- {member.user?.avatar?.url ? ( - Avatar - ) : ( - member.user?.username?.charAt(0).toUpperCase() - )} -
-
-

{member.user?.username}

-
- - {member.projectRole?.customLabel || member.projectRole?.jobTitleTag?.labelEn || member.project_role?.custom_label || member.project_role?.job_title_tag?.label_en || 'Member'} - + {(project.members || []).map((member, i) => { + const memberId = member.documentId || member.uid; + const ghState = githubEditing[memberId]; + const currentGithub = member.githubUsername || member.github_username || ''; + + return ( +
+ {/* Top Row: Avatar + Info + Actions */} +
+
+ {member.user?.avatar?.url ? ( + Avatar + ) : ( + member.user?.username?.charAt(0).toUpperCase() + )} +
+
+

{member.user?.username}

+
+ + {member.projectRole?.customLabel || member.projectRole?.jobTitleTag?.labelEn || member.project_role?.custom_label || member.project_role?.job_title_tag?.label_en || 'Member'} + + + {(member.projectRole?.permissions?.admin || member.project_role?.permissions?.admin) ? 'Admin' : 'Custom'} + +
+
+ {canManageMembers && ( + + )} +
+ + {/* GitHub Username Row */} +
+ {ghState?.editing ? ( + /* Edit Mode */ +
+ + setGithubEditing(prev => ({ + ...prev, + [memberId]: { ...ghState, value: e.target.value } + }))} + placeholder="github-username" + className="dark-input text-[11px] font-mono flex-1" + onPressEnter={() => saveGithubUsername(member)} + autoFocus + /> + + +
+ ) : ( + /* Display Mode */ +
+ + {currentGithub ? ( + + @{currentGithub} + + ) : ( + GitHub not linked + )} + {/* Only allow editing if the member is the current user */} + {(member.user?.uid === user?.uid || member.user?.id === user?.id) && ( + + + + )} +
+ )}
- {/* Permissions & Actions */} -
- - {(member.projectRole?.permissions?.admin || member.project_role?.permissions?.admin) ? 'Admin' : 'Custom'} - - - {canManageMembers && ( - - )} -
-
- ))} + ); + })}
+ {/* Project Roles */} @@ -387,6 +497,14 @@ export const ProjectMembers = ({ project, canManageMembers, projectApplication,
+
+ Feed +
+ + Publish Job Opportunity to Feed + +
+
diff --git a/src/presentation/feature/project/routes/ProjectDashboard.jsx b/src/presentation/feature/project/routes/ProjectDashboard.jsx index 60eb868..8949548 100644 --- a/src/presentation/feature/project/routes/ProjectDashboard.jsx +++ b/src/presentation/feature/project/routes/ProjectDashboard.jsx @@ -12,6 +12,7 @@ import { useProjectRole } from '@core/hooks/useProjectRole'; import { useRole } from '@core/hooks/useRole'; import { useProjectApplications } from '@domain/useCase/project/useProjectApplications'; import { useProjectMembers } from '@domain/useCase/project/useProjectMembers'; +import { useUpdateProject } from '@domain/useCase/project/useUpdateProject'; import { Modal, Form, Select, Input, Button } from 'antd'; import { toast } from 'react-hot-toast'; @@ -314,6 +315,8 @@ export const ProjectDashboard = () => { const ProjectSettings = ({ project }) => { const [copied, setCopied] = useState(false); + const [form] = Form.useForm(); + const { updateProject, isUpdating } = useUpdateProject(project.uid); const webhookUrl = `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:1338/api'}/github-event/webhook`; const handleCopy = () => { @@ -322,6 +325,18 @@ const ProjectSettings = ({ project }) => { setTimeout(() => setCopied(false), 2000); }; + const handleUpdate = async (values) => { + try { + await updateProject({ + github_repo_id: values.github_repo_id || null, + github_webhook_secret: values.github_webhook_secret || null + }); + toast.success("GitHub configuration updated successfully!"); + } catch (err) { + toast.error(err.message || "Failed to update configuration"); + } + }; + return (
@@ -330,14 +345,25 @@ const ProjectSettings = ({ project }) => { Automate your Kanban Board. Link your GitHub repository to automatically move tasks when Pull Requests are opened or merged, and update CI statuses based on GitHub Actions.

-
+
+ {/* Setup Instructions */}
-
+
{webhookUrl}
- -
- Configure in Project Settings + +
+ {['Pushes', 'Pull requests', 'Pull request reviews', 'Check runs'].map(event => ( + + {event} + + ))}
-
- -
- {['Pushes', 'Pull requests', 'Pull request reviews', 'Check runs'].map(event => ( - - {event} - - ))} -
+
+ + {/* Configuration Form */} +
+ GitHub Repository ID} + tooltip="The numeric ID of your GitHub repository (e.g., 123456789)" + > + + + + Webhook Secret (Optional)} + tooltip="The secret you configured in GitHub to secure the webhook" + > + +
-
-
- -
+
+ +
+
); diff --git a/src/presentation/shared/components/layout/Header.jsx b/src/presentation/shared/components/layout/Header.jsx index 35c8045..a53d5f8 100644 --- a/src/presentation/shared/components/layout/Header.jsx +++ b/src/presentation/shared/components/layout/Header.jsx @@ -6,7 +6,8 @@ import { BookOpen, Code2, Map, LayoutDashboard, FileText, Calendar, Video, Image, Database, Menu, X, Users, - Github + Github, + FolderKanban } from 'lucide-react'; import { cn } from '@core/utils/cn'; import { PATHS } from '../../../routes/paths'; @@ -154,7 +155,9 @@ const NAV_COMMUNITY = [ { icon: Calendar, label: 'Events', path: PATHS.EVENTS }, ]; -const NAV_WORKSPACES = []; +const NAV_WORKSPACES = [ + { icon: FolderKanban, label: 'Projects', path: PATHS.PROJECTS } +]; export const Header = () => { const location = useLocation(); @@ -215,7 +218,6 @@ export const Header = () => { toggleDropdown('workspaces')} onClose={() => setOpenDropdown(null)} @@ -323,10 +325,16 @@ export const Header = () => { {/* Workspaces */}
Workspaces
-
-

Coming Soon

-

Inshaa Allah

-
+ {NAV_WORKSPACES.map(({ icon: Icon, label, path }) => ( + + {label} + + ))} {/* CMS — Publisher Only (Mobile) */} diff --git a/src/presentation/shared/components/layout/Sidebar.jsx b/src/presentation/shared/components/layout/Sidebar.jsx index 7379827..4f4c1b4 100644 --- a/src/presentation/shared/components/layout/Sidebar.jsx +++ b/src/presentation/shared/components/layout/Sidebar.jsx @@ -15,7 +15,8 @@ import { UserCircle, Settings, LogOut, - Database + Database, + FolderKanban } from 'lucide-react'; import { Link, useLocation } from 'react-router-dom'; import { PATHS } from '@presentation/routes/paths'; @@ -100,6 +101,9 @@ export const Sidebar = () => { + + +
From 50ed19e4c3e839b6e7a6dedb5986369d938c421c Mon Sep 17 00:00:00 2001 From: mohamed mahmoud Date: Fri, 11 Sep 2026 14:43:05 +0300 Subject: [PATCH 2/2] SECURITY: remove .env and block from tracking --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a547bf3..2d2c8d2 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ dist-ssr *.njsproj *.sln *.sw? +.env*