diff --git a/package.json b/package.json index 9b1978b4..9a47ffa2 100644 --- a/package.json +++ b/package.json @@ -26,11 +26,12 @@ "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-progress": "^1.1.7", - "@radix-ui/react-radio-group": "^1.3.7", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", + "@radix-ui/react-tabs": "^1.1.13", "@svgr/webpack": "^8.1.0", "@tailwindcss/forms": "^0.5.5", "@tanstack/react-query": "^5.66.0", diff --git a/public/devpost.svg b/public/devpost.svg new file mode 100644 index 00000000..6a017c31 --- /dev/null +++ b/public/devpost.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/app/(default)/expo/page.tsx b/src/app/(default)/expo/page.tsx index 00e4e7f7..eae8f505 100644 --- a/src/app/(default)/expo/page.tsx +++ b/src/app/(default)/expo/page.tsx @@ -14,8 +14,11 @@ import { } from "@/components/ui/select"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Search, Filter, Folder, ExternalLink } from "lucide-react"; +import { Search, Filter, Folder, ExternalLink, Github } from "lucide-react"; import { Loader2 } from "lucide-react"; +import Image from "next/image"; +import Devpost from "../../../../public/devpost.svg"; +import { m } from "framer-motion"; export default function ExpoPage() { const { data: projects, isLoading, error } = useAllProjects(); @@ -207,13 +210,25 @@ export default function ExpoPage() { variant="outline" size="sm" className="w-full" + style={{ marginBottom: "6px" }} onClick={() => window.open(project.devpostLink, "_blank") } > - + View on Devpost + )} diff --git a/src/app/(protected)/photos/page.tsx b/src/app/(protected)/photos/page.tsx index 32909903..fe9f787e 100644 --- a/src/app/(protected)/photos/page.tsx +++ b/src/app/(protected)/photos/page.tsx @@ -3,38 +3,49 @@ import React, { useState } from "react"; import PhotoUpload from "@/components/PhotoUpload"; import { usePhotos } from "@/lib/api/photo"; -import { PHOTO_MILESTONES } from "@/lib/api/photo"; import { Toaster } from "sonner"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { Dialog, DialogContent } from "@/components/ui/dialog"; -import { Camera, Image as ImageIcon, Clock, X, ChevronLeft, ChevronRight } from "lucide-react"; +import { Camera, X, ChevronLeft, ChevronRight, Lock } from "lucide-react"; import { useFirebase } from "@/lib/providers/FirebaseProvider"; -import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; export default function PhotosPage() { const { data: photos, isLoading, refetch } = usePhotos(); const { user } = useFirebase(); const [selectedImageIndex, setSelectedImageIndex] = useState(null); - const [viewingMyPhotos, setViewingMyPhotos] = useState(false); + const [viewingTab, setViewingTab] = useState<"my-photos" | "community">("my-photos"); - // Separate my photos (all of them) from community photos (only public) - const myPhotos = photos?.filter((photo) => { + // Separate my photos into public and private + const myPublicPhotos = photos?.filter((photo) => { const userId = photo.name.split("_")[0]; - return userId === user?.uid; + const fileType = photo.name.split("_")[1]; + return userId === user?.uid && fileType === "public"; + }) || []; + + const myPrivatePhotos = photos?.filter((photo) => { + const userId = photo.name.split("_")[0]; + const fileType = photo.name.split("_")[1]; + return userId === user?.uid && fileType === "private"; }) || []; + const myPhotos = [...myPublicPhotos, ...myPrivatePhotos]; + const communityPhotos = photos?.filter((photo) => { const userId = photo.name.split("_")[0]; const fileType = photo.name.split("_")[1]; return userId !== user?.uid && fileType === "public"; }) || []; - const currentPhotos = viewingMyPhotos ? myPhotos : communityPhotos; + const getPhotoArray = () => { + return viewingTab === "my-photos" ? myPhotos : communityPhotos; + }; + + const currentPhotos = getPhotoArray(); const selectedPhoto = selectedImageIndex !== null ? currentPhotos[selectedImageIndex] : null; - const openLightbox = (index: number, isMyPhotos: boolean) => { + const openLightbox = (index: number) => { setSelectedImageIndex(index); - setViewingMyPhotos(isMyPhotos); }; const closeLightbox = () => { @@ -59,150 +70,191 @@ export default function PhotosPage() { if (e.key === "Escape") closeLightbox(); }; + const isPhotoPrivate = (photo: { name: string; url: string; createdAt: string }) => { + const fileType = photo.name.split("_")[1]; + return fileType === "private"; + }; + return ( <> -
+
{/* Header */} -
-
-

- Photo Gallery -

-

- Share your HackPSU experience with the community. Upload photos, browse what others have captured, and relive the highlights of the event. -

-
+
+

+ Photo Gallery +

+

+ Capture and share your HackPSU moments. Upload photos as public to share with the community, or keep them private just for you. +

{/* Upload Section */} -
-
-

- Share a Photo -

-

- Upload a photo to the public gallery and let everyone see your HackPSU moment. -

-
+
refetch()} />
- {/* My Photos Section */} - {myPhotos.length > 0 && ( -
-
-

Your Photos

-

- {myPhotos.length} {myPhotos.length === 1 ? "photo" : "photos"} uploaded -

-
-
- {myPhotos.map((photo, idx) => ( - - ))} -
-
- )} - - {/* Community Gallery Section */} -
-
-

- Community Highlights -

-

- {isLoading - ? "Loading gallery..." - : communityPhotos.length > 0 - ? `${communityPhotos.length} ${communityPhotos.length === 1 ? "photo" : "photos"} from the HackPSU community` - : "Waiting for the first community photo"} -

-
- - {isLoading ? ( -
-
-
-

Loading gallery...

+ {/* Gallery Tabs */} + setViewingTab(v as "my-photos" | "community")} className="w-full"> + + + My Photos + {myPhotos.length > 0 && ( + + {myPhotos.length} + + )} + + + Community + {communityPhotos.length > 0 && ( + + {communityPhotos.length} + + )} + + + + {/* My Photos Tab */} + + {isLoading ? ( +
+
+
+

Loading your photos...

+
-
- ) : communityPhotos.length > 0 ? ( -
- {communityPhotos.map((photo, idx) => ( - - ))} -
- ) : ( - - -
-
- + ) : myPhotos.length > 0 ? ( +
+
+

+ {myPublicPhotos.length} public • {myPrivatePhotos.length} private +

+
+
+ {myPhotos.map((photo, idx) => ( + + ))} +
+
+ ) : ( + + +
+
+ +
+

+ No Photos Yet +

+

+ Upload your first photo above to start your gallery. You can choose to make it public or keep it private. +

-

- {myPhotos.length > 0 ? "You're the First!" : "Gallery Opening Soon"} -

-

- {myPhotos.length > 0 - ? "Your photo is the first in the gallery. Others will join soon!" - : "Upload a photo above to start the gallery and inspire others to share their moments."} + + + )} + + + {/* Community Tab */} + + {isLoading ? ( +

+
+
+

Loading community photos...

+
+
+ ) : communityPhotos.length > 0 ? ( +
+
+

+ {communityPhotos.length} {communityPhotos.length === 1 ? "photo" : "photos"} from the HackPSU community

- - - )} -
+
+ {communityPhotos.map((photo, idx) => ( + + ))} +
+
+ ) : ( + + +
+
+ +
+

+ No Community Photos Yet +

+

+ Be the first to share a public photo with the HackPSU community! Upload a photo above and select "Public" to get started. +

+
+
+
+ )} + +
{/* Lightbox Modal */} -
+
{/* Close Button */} {/* Previous Button */} {selectedImageIndex !== null && selectedImageIndex > 0 && ( )} @@ -210,28 +262,32 @@ export default function PhotosPage() { {selectedImageIndex !== null && selectedImageIndex < currentPhotos.length - 1 && ( )} {/* Image Container */} {selectedPhoto && ( -
+
{selectedPhoto.name} - {/* Image Counter */} -
- {selectedImageIndex !== null && `${selectedImageIndex + 1} / ${currentPhotos.length}`} + {/* Image Counter & Privacy Badge */} +
+
+ {selectedImageIndex !== null && `${selectedImageIndex + 1} / ${currentPhotos.length}`} +
+ {isPhotoPrivate(selectedPhoto) && ( +
+ + Private +
+ )}
)} diff --git a/src/app/(protected)/project/page.tsx b/src/app/(protected)/project/page.tsx index 97fdfde5..f08ba0b3 100644 --- a/src/app/(protected)/project/page.tsx +++ b/src/app/(protected)/project/page.tsx @@ -42,6 +42,7 @@ export default function Project() { const { data: teams, error: teamsError } = useAllTeams(); const [projectName, setProjectName] = useState(""); + const [githubLink, setGithubLink] = useState (""); const [devpostLink, setDevpostLink] = useState(""); const [selectedCategories, setSelectedCategories] = useState< ProjectCategory[] @@ -107,6 +108,7 @@ export default function Project() { useEffect(() => { if (existingProject) { setProjectName(existingProject.name); + setGithubLink(existingProject.githubLink || ""); setDevpostLink(existingProject.devpostLink || ""); if (existingProject.categories) { const categories = existingProject.categories @@ -141,6 +143,19 @@ export default function Project() { return; } + // Validate Github Link - required + if (!githubLink.trim()) { + toast.error("Github link is required"); + return; + } + + if (!isValidGithubUrl(githubLink)) { + toast.error( + "Please enter a valid Github URL (must be from github.com)" + ); + return; + } + // Validate Devpost link - now required if (!devpostLink.trim()) { toast.error("Devpost link is required"); @@ -168,6 +183,7 @@ export default function Project() { ? selectedCategories.join(", ") : undefined, teamId: userTeam.id, + githubLink: githubLink.trim(), devpostLink: devpostLink.trim(), }; @@ -187,6 +203,18 @@ export default function Project() { } }; + const isValidGithubUrl = (url: string) => { + try { + const parsedUrl = new URL(url); + return ( + parsedUrl.hostname === "github.com" || + parsedUrl.hostname === "www.github.com" + ); + } catch (_) { + return false; + } + }; + const isValidDevpostUrl = (url: string) => { try { const parsedUrl = new URL(url); @@ -325,6 +353,19 @@ export default function Project() { />
+ {/* Github Link */} +
+ + setGithubLink(e.target.value)} + disabled={!canUpdateProject} + /> +
+ {/* Devpost Link */}
diff --git a/src/components/PhotoUpload.tsx b/src/components/PhotoUpload.tsx index 1fc19b5e..1a0570fb 100644 --- a/src/components/PhotoUpload.tsx +++ b/src/components/PhotoUpload.tsx @@ -2,19 +2,13 @@ import React, { useRef, useState } from "react"; import { useUploadPhoto } from "@/lib/api/photo"; -import { PHOTO_MILESTONES } from "@/lib/api/photo"; import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Card, CardContent } from "@/components/ui/card"; -import { Upload, X, Camera, Loader2 } from "lucide-react"; +import { Upload, X, Camera, Loader2, Lock, Globe } from "lucide-react"; import { toast } from "sonner"; import { Progress } from "@/components/ui/progress"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; interface FileWithPreview { file: File; @@ -28,7 +22,7 @@ export default function PhotoUpload({ }) { const inputRef = useRef(null); const [files, setFiles] = useState([]); - const [fileType, setFileType] = useState("public"); + const [isPublic, setIsPublic] = useState(true); const [isDragging, setIsDragging] = useState(false); const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState<{current: number, total: number}>({current: 0, total: 0}); @@ -114,8 +108,8 @@ export default function PhotoUpload({ }; const startUpload = async () => { - if (files.length === 0 || !fileType) { - toast.error("Please select at least one file and category"); + if (files.length === 0) { + toast.error("Please select at least one file"); return; } @@ -124,6 +118,7 @@ export default function PhotoUpload({ let successCount = 0; let failCount = 0; + const fileType = isPublic ? "public" : "private"; for (let i = 0; i < files.length; i++) { try { @@ -139,7 +134,7 @@ export default function PhotoUpload({ // Clean up files.forEach(f => URL.revokeObjectURL(f.preview)); setFiles([]); - setFileType("public"); + setIsPublic(true); setIsUploading(false); setUploadProgress({current: 0, total: 0}); onUploaded?.(); @@ -147,7 +142,7 @@ export default function PhotoUpload({ // Show summary toast if (successCount > 0) { toast.success(`${successCount} ${successCount === 1 ? "photo" : "photos"} uploaded successfully!`, { - description: "All photos are reviewed by our team before appearing in the gallery. This typically takes a few minutes.", + description: "Your photos are being reviewed by our moderation team and will appear in your gallery once approved.", duration: 6000, }); } @@ -157,22 +152,20 @@ export default function PhotoUpload({ } }; - const selectedMilestone = PHOTO_MILESTONES.find((m) => m.id === fileType); - return ( - + -
+
{/* Upload Area */}
e.preventDefault()} onDragEnter={() => setIsDragging(true)} onDragLeave={() => setIsDragging(false)} onDrop={handleDrop} - className={`relative border-2 border-dashed rounded-lg p-8 transition-colors ${ + className={`relative border-2 border-dashed rounded-xl p-8 transition-all ${ isDragging - ? "border-primary bg-primary/5" - : "border-muted-foreground/25 hover:border-muted-foreground/50" + ? "border-primary bg-primary/10 scale-[1.02]" + : "border-muted-foreground/25 hover:border-muted-foreground/50 hover:bg-muted/20" }`} > 0 ? (
-
+
{files.map((fileWithPreview, idx) => ( -
+
{fileWithPreview.file.type.startsWith("video/") ? (
))}
-
-

+

+

{files.length} {files.length === 1 ? "file" : "files"} selected

) : ( -
- +
+
+ +

Upload Photos or Videos

-

- Drag and drop your files here, or click to browse +

+ Drag and drop your files here, or click the button below to browse

-

+

Supports: JPG, PNG, GIF, WebP, MP4, MOV (max 100MB each)

)}
- {/* Milestone Selection */} -
- - - {selectedMilestone && ( -

- {selectedMilestone.description} + {/* Privacy Toggle */} +

+ +
+ + + +

+ All photos are reviewed by our moderation team before appearing in galleries. Public photos will be visible to everyone once approved. Private photos will only be visible to you.

- )} +
+ setIsPublic(value === "public")} + disabled={isUploading} + className="grid grid-cols-1 sm:grid-cols-2 gap-3" + > +
+ + +
+
+ + +
+
{/* Upload Progress */} {isUploading && ( -
+
- + Uploading {uploadProgress.current} of {uploadProgress.total} - + {Math.round((uploadProgress.current / uploadProgress.total) * 100)}%
- +
)} - {/* Upload Button */} -
+ {/* Action Buttons */} +
{files.length > 0 && !isUploading && ( @@ -318,8 +346,10 @@ export default function PhotoUpload({ files.forEach(f => URL.revokeObjectURL(f.preview)); setFiles([]); }} + className="h-11" > - Clear All + + Clear )}
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 00000000..0f4caebb --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/src/lib/api/judging/entity.ts b/src/lib/api/judging/entity.ts index 76aa446d..3f28be6f 100644 --- a/src/lib/api/judging/entity.ts +++ b/src/lib/api/judging/entity.ts @@ -21,6 +21,7 @@ export interface ProjectEntity { hackathonId: string; categories?: string; teamId?: string; + githubLink?: string; devpostLink?: string; } diff --git a/src/styles/globals.css b/src/styles/globals.css index b512dca6..754e5bf6 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -72,6 +72,11 @@ /* Account for fixed size of the Navbar upon jumping to links. */ + html { + overflow-x: hidden; + width: 100%; + } + body { font-family: "Lato", "Georgia", sans-serif; font-feature-settings: @@ -81,6 +86,9 @@ min-height: 100vh; margin: 0; overscroll-behavior: none; + overflow-x: hidden; + width: 100%; + position: relative; } .dark { --background: 0 0% 3.9%; diff --git a/yarn.lock b/yarn.lock index 04160049..e0e71f25 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2441,7 +2441,7 @@ "@radix-ui/react-context" "1.1.2" "@radix-ui/react-primitive" "2.1.3" -"@radix-ui/react-radio-group@^1.3.7": +"@radix-ui/react-radio-group@^1.3.8": version "1.3.8" resolved "https://registry.yarnpkg.com/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz#93f102b5b948d602c2f2adb1bc5c347cbaf64bd9" integrity sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ== @@ -2526,6 +2526,20 @@ "@radix-ui/react-use-previous" "1.1.1" "@radix-ui/react-use-size" "1.1.1" +"@radix-ui/react-tabs@^1.1.13": + version "1.1.13" + resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz#3537ce379d7e7ff4eeb6b67a0973e139c2ac1f15" + integrity sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A== + dependencies: + "@radix-ui/primitive" "1.1.3" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-direction" "1.1.1" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-presence" "1.1.5" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-roving-focus" "1.1.11" + "@radix-ui/react-use-controllable-state" "1.2.2" + "@radix-ui/react-use-callback-ref@1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz#62a4dba8b3255fdc5cc7787faeac1c6e4cc58d40"