diff --git a/app/admin/projects/[projectId]/page.tsx b/app/admin/projects/[projectId]/page.tsx new file mode 100644 index 00000000..7fdb03d2 --- /dev/null +++ b/app/admin/projects/[projectId]/page.tsx @@ -0,0 +1,653 @@ +"use client" + +import { useState, useEffect, useActionState } from 'react'; +import { useSession } from 'next-auth/react'; +import type { FormSave } from '@/components/form/FormInput'; +import { useRouter } from 'next/navigation'; +import { useIsMobile } from '@/lib/hooks'; +import Link from 'next/link'; +import { ReviewModeProvider } from '@/app/contexts/ReviewModeContext'; +import ProjectStatus from '@/components/common/ProjectStatus'; +import ReviewSection from '@/components/common/ReviewSection'; +import ImageWithFallback from '@/components/common/ImageWithFallback'; +import { ProjectFlags } from '@/components/common/ProjectFlagsEditor'; +import Icon from "@hackclub/icons"; +import Modal from '@/components/common/Modal'; +import { toast, Toaster } from 'sonner'; +import FormInput from '@/components/form/FormInput'; +import { ensureHttps } from '@/lib/utils'; + +interface User { + id: string; + name: string | null; + email: string | null; + image: string | null; +} + +interface Project { + projectID: string; + name: string; + description: string; + codeUrl: string; + playableUrl: string; + screenshot: string; + shipped: boolean; + viral: boolean; + approved: boolean; + in_review: boolean; + userId: string; + user: User; + reviews: { id: string }[]; + hackatime?: string; + rawHours: number; + hoursOverride?: number; +} + +async function editProjectAction(state: FormSave, formData: FormData): Promise { + const response = await fetch('/api/projects', { + method: 'PUT', + body: formData + }); + + if (!response.ok) { + throw new Error('Failed to edit project'); + } + + return await response.json(); +} + +// Create a wrapper component that will use the ReviewModeProvider +function ProjectDetailContent({ params }: { params: { projectId: string } }) { + const { data: session, status } = useSession(); + const router = useRouter(); + const [project, setProject] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [isProjectEditModalOpen, setIsProjectEditModalOpen] = useState(false); + const [isDeleteConfirmModalOpen, setIsDeleteConfirmModalOpen] = useState(false); + const isMobile = useIsMobile(); + + const [initialEditState, setInitialEditState] = useState({ + name: "", + description: "", + hackatime: "", + codeUrl: "", + playableUrl: "", + screenshot: "", + userId: "", + projectID: "", + hoursOverride: undefined + }); + + const [projectEditState, projectEditFormAction, projectEditPending] = useActionState((state: FormSave, payload: FormData) => new Promise((resolve, reject) => { + toast.promise(editProjectAction(state, payload), { + loading: "Editing project...", + error: () => { reject(); return "Failed to edit project" }, + success: data => { + if (!data?.data) { + reject(new Error('No project data received')); + return "Failed to edit project"; + } + resolve(data); + setIsProjectEditModalOpen(false); + + // Update the project with edited data + if (project) { + setProject({...project, ...data.data}); + } + + return "Project updated successfully" + } + }); + }), { + errors: undefined, + data: { + name: "", + description: "", + hackatime: "", + codeUrl: "", + playableUrl: "", + screenshot: "", + userId: "", + projectID: "", + hoursOverride: undefined + } + }); + + useEffect(() => { + async function fetchProject() { + if (status !== 'authenticated') return; + + try { + setIsLoading(true); + const response = await fetch(`/api/admin/projects/${params.projectId}`); + + if (!response.ok) { + throw new Error(`Failed to fetch project: ${response.statusText}`); + } + + const data = await response.json(); + setProject(data); + } catch (err) { + console.error('Error fetching project:', err); + setError(err instanceof Error ? err.message : 'Failed to load project'); + } finally { + setIsLoading(false); + } + } + + fetchProject(); + }, [params.projectId, status]); + + // Handle project flags updates + const handleFlagsUpdated = (updatedProject: any) => { + if (project) { + setProject({ + ...project, + shipped: updatedProject.shipped, + viral: updatedProject.viral, + in_review: updatedProject.in_review, + approved: updatedProject.approved + }); + } + }; + + // This useEffect watches for changes to project and initialEditState + // and ensures the project edit form fields are properly synchronized + useEffect(() => { + if (project) { + // Update initialEditState with current project values + setInitialEditState({ + name: project.name || "", + description: project.description || "", + hackatime: project.hackatime || "", + codeUrl: project.codeUrl || "", + playableUrl: project.playableUrl || "", + screenshot: project.screenshot || "", + userId: project.userId || "", + projectID: project.projectID || "", + viral: project.viral || false, + shipped: project.shipped || false, + in_review: project.in_review || false, + approved: project.approved || false, + hoursOverride: project.hoursOverride + }); + } + }, [project]); + + useEffect(() => { + if (initialEditState.projectID) { + // Update projectEditState with initialEditState values + projectEditState.data = { + ...projectEditState.data, + name: initialEditState.name || "", + description: initialEditState.description || "", + hackatime: initialEditState.hackatime || "", + codeUrl: initialEditState.codeUrl || "", + playableUrl: initialEditState.playableUrl || "", + screenshot: initialEditState.screenshot || "", + userId: initialEditState.userId || "", + projectID: initialEditState.projectID || "", + viral: initialEditState.viral || false, + shipped: initialEditState.shipped || false, + in_review: initialEditState.in_review || false, + approved: initialEditState.approved || false, + hoursOverride: initialEditState.hoursOverride + }; + } + }, [initialEditState, projectEditState]); + + if (status !== 'authenticated') { + return ( +
+
+

Access Denied

+

You need to be logged in to access the admin area.

+ + Sign In + +
+
+ ); + } + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (error || !project) { + return ( +
+

Error

+

{error || 'Project not found'}

+ + Back to Projects + +
+ ); + } + + // Prepare project flags + const projectFlags: ProjectFlags = { + shipped: !!project.shipped, + viral: !!project.viral, + in_review: !!project.in_review + }; + + return ( +
+
+ + ← Back to Projects + +

{project.name}

+ {!isMobile && ( + + )} +
+ +
+ {/* Project Description */} +
+

Description

+

{project.description || "No description provided."}

+
+ + {/* Project Status */} +
+
+ +
+
+ + {/* Status Flags */} +
+

Project Status

+
+
+ Viral +
+
+
+ Shipped +
+
+
+ In Review +
+
+ + {/* Project Hours */} +
+

Project Hours

+
+
+ Raw Hackatime Hours +

{project.rawHours}h

+
+
+ Admin Hours Override +

+ {project.hoursOverride !== undefined && project.hoursOverride !== null + ? `${project.hoursOverride}h` + : '—'} +

+
+
+ Effective Hours +

+ {(project.hoursOverride !== undefined && project.hoursOverride !== null) + ? `${project.hoursOverride}h` + : `${project.rawHours}h`} +

+
+
+
+ + {/* User Info */} +
+

Creator

+
+ {project.user.image ? ( + {project.user.name + ) : ( +
+ + {(project.user.name || project.user.email || 'U').charAt(0).toUpperCase()} + +
+ )} +
+

{project.user.name || 'Unknown'}

+

{project.user.email}

+
+
+
+ + {/* Project Links */} + {(project.codeUrl || project.playableUrl) && ( +
+

Links

+
+ {project.codeUrl && ( + + + View Code Repository + + )} + {project.playableUrl && ( + + + Try It Out + + )} +
+
+ )} + + {/* Project Screenshot */} + {project.screenshot && ( +
+

Screenshot

+
+ +
+
+ )} + + {/* Reviews Section */} +
+ +
+
+ + {/* Edit Project Modal */} + setIsProjectEditModalOpen(false)} + title="Edit Project" + hideFooter={true} + > +
+ + +
+ + Project Name + + + Description + +
+ +
+

Project URLs

+ + Code URL + + + Playable URL + + + Screenshot URL + +
+ +
+

Project Hours

+
+
+
+ +
+
+ {project.rawHours} +
+
+
+
+ +
+ + + (Hackatime reported: {project.rawHours}h) + +
+
+
+
+ +
+

Project Status

+
+ + +
+
+ + +
+
+ + +
+
+ + {/* Delete Project Section */} +
+

Danger Zone

+

+ Once you delete a project, there is no going back. Please be certain. +

+ +
+ +
+ +
+
+
+ + {/* Delete Confirmation Modal */} + setIsDeleteConfirmModalOpen(false)} + title="Delete Project?" + hideFooter={true} + > +
+

+ Are you sure you want to delete {project.name}? +

+

+ This action cannot be undone. It will permanently delete the project and all associated data. +

+ +
+ + + +
+
+
+ + {/* Mobile Edit Button Overlay */} + {isMobile && ( +
+ +
+ )} + + +
+ ); +} + +// Main component that wraps the content with ReviewModeProvider +export default function ProjectDetail({ params }: { params: { projectId: string } }) { + return ( + + + + ); +} \ No newline at end of file diff --git a/app/bay/page.tsx b/app/bay/page.tsx index 2733ca4a..df3231ed 100644 --- a/app/bay/page.tsx +++ b/app/bay/page.tsx @@ -27,9 +27,9 @@ import ProjectReviewRequest from '@/components/common/ProjectReviewRequest'; import ImageWithFallback from '@/components/common/ImageWithFallback'; import CompleteReviewForm from '@/components/common/CompleteReviewForm'; import ProjectMetadataWarning from '@/components/common/ProjectMetadataWarning'; +import { ensureHttps } from '@/lib/utils'; import IDPopup from '@/app/components/identity/IDPopup'; - // Force dynamic rendering to prevent prerendering errors during build export const dynamic = 'force-dynamic'; @@ -424,7 +424,7 @@ function ProjectDetail({