Skip to content
This repository was archived by the owner on Apr 21, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions app/api/gallery/challenge-tags/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { prisma } from '@/lib/prisma';
import { opts } from '@/app/api/auth/[...nextauth]/route';

export async function GET() {
try {
// Check for valid session - user must be logged in
const session = await getServerSession(opts);
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

// Fetch tags that start with 'challenge' and have at least one project association
const challengeTags = await prisma.tag.findMany({
where: {
name: {
startsWith: 'challenge'
},
projectTags: {
some: {
// Only include tags that are actually used by projects
project: {
projectTags: {
some: {
tag: {
name: 'island-project'
}
}
}
}
}
}
},
select: {
id: true,
name: true,
description: true,
color: true,
_count: {
select: {
projectTags: {
where: {
project: {
projectTags: {
some: {
tag: {
name: 'island-project'
}
}
}
}
}
}
}
}
},
orderBy: {
name: 'asc'
}
});

// Transform the data to include project counts
const tagsWithCounts = challengeTags.map(tag => ({
id: tag.id,
name: tag.name,
description: tag.description,
color: tag.color,
projectCount: tag._count.projectTags
}));

return NextResponse.json(tagsWithCounts);

} catch (error) {
console.error('Error fetching challenge tags:', error);
return NextResponse.json(
{ error: 'Failed to fetch challenge tags' },
{ status: 500 }
);
}
}
25 changes: 24 additions & 1 deletion app/api/gallery/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ export async function GET(request: Request) {
// Check if user is admin to determine tag exposure
const isAdmin = session.user.role === 'Admin' || session.user.isAdmin === true;

// Get experience mode from query params for server-side filtering
// Get experience mode and challenge tags from query params for server-side filtering
const { searchParams } = new URL(request.url);
const isIslandMode = searchParams.get('isIslandMode') === 'true';
const challengeTagsParam = searchParams.get('challengeTags');
const selectedChallengeTags = challengeTagsParam ? challengeTagsParam.split(',').filter(tag => tag.trim()) : [];

// Build where clause for server-side filtering based on experience mode
let whereClause: any = {};
Expand All @@ -37,6 +39,27 @@ export async function GET(request: Request) {
}
}
};

// If specific challenge tags are selected, add that filter
// Projects must have ALL selected tags (AND logic)
if (selectedChallengeTags.length > 0) {
const challengeTagConstraints = selectedChallengeTags.map(tagName => ({
projectTags: {
some: {
tag: {
name: tagName
}
}
}
}));

whereClause = {
AND: [
whereClause,
...challengeTagConstraints
]
};
}
} else {
// Voyage mode: only show projects WITHOUT 'island-project' tag
whereClause = {
Expand Down
104 changes: 101 additions & 3 deletions app/gallery/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ interface Project {
}[];
}

interface ChallengeTag {
id: string;
name: string;
description?: string;
color?: string;
projectCount: number;
}

type SortOption = 'hasImage' | 'hours' | 'alphabetical' | 'upvotes' | 'discussions' | 'recentChat';

// Helper function to check if a URL is a valid image
Expand Down Expand Up @@ -83,6 +91,10 @@ function GalleryInner() {
const [showViral, setShowViral] = useState(false);
const [showShipped, setShowShipped] = useState(false);
const [sortBy, setSortBy] = useState<SortOption>('upvotes');

// Challenge tag filtering state
const [challengeTags, setChallengeTags] = useState<ChallengeTag[]>([]);
const [selectedChallengeTags, setSelectedChallengeTags] = useState<string[]>([]);

// Upvote loading state
const [upvotingProjects, setUpvotingProjects] = useState<Set<string>>(new Set());
Expand Down Expand Up @@ -111,17 +123,51 @@ function GalleryInner() {
if (sortBy === 'hours') {
setSortBy('upvotes'); // Default to upvotes sort in island mode
}
} else {
// Reset challenge filters when leaving island mode
setSelectedChallengeTags([]);
}
}, [isIslandMode, sortBy]);

// Calculate total projects for current experience mode (server-side filtered, so just use length)
const totalProjectsForCurrentMode = projects.length;

// Fetch challenge tags when in island mode
useEffect(() => {
async function fetchChallengeTags() {
if (!isIslandMode) {
setChallengeTags([]);
return;
}

try {
const response = await fetch('/api/gallery/challenge-tags', {
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' }
});

if (response.ok) {
const data = await response.json();
setChallengeTags(data);
} else {
console.error('Failed to fetch challenge tags');
}
} catch (error) {
console.error('Error fetching challenge tags:', error);
}
}

if (status === 'authenticated' && !isExperienceModeLoading) {
fetchChallengeTags();
}
}, [status, isIslandMode, isExperienceModeLoading]);

useEffect(() => {
async function fetchProjects() {
try {
const timestamp = Date.now();
const url = `/api/gallery?isIslandMode=${isIslandMode}&_t=${timestamp}`;
const challengeTagsQuery = selectedChallengeTags.length > 0 ? `&challengeTags=${selectedChallengeTags.join(',')}` : '';
const url = `/api/gallery?isIslandMode=${isIslandMode}${challengeTagsQuery}&_t=${timestamp}`;

const response = await fetch(url, {
cache: 'no-store',
Expand All @@ -145,11 +191,11 @@ function GalleryInner() {
if (status === 'authenticated' && !isExperienceModeLoading) {
fetchProjects();
}
}, [status, isIslandMode, isExperienceModeLoading]);
}, [status, isIslandMode, isExperienceModeLoading, selectedChallengeTags]);

// Filter and sort projects (server-side filtering by experience mode already applied)
const filteredAndSortedProjects = useMemo(() => {
let filtered = projects.filter(project => {
const filtered = projects.filter(project => {
// Search filter
const searchLower = searchQuery.toLowerCase();
const matchesSearch = searchQuery === '' ||
Expand Down Expand Up @@ -445,6 +491,58 @@ function GalleryInner() {
</div>
</div>

{/* Challenge Tags Filter - Only show in island mode */}
{isIslandMode && challengeTags.length > 0 && (
<div className="mt-6 pt-6 border-t border-gray-200">
<div className="flex items-center justify-between mb-3">
<label className="text-sm font-medium text-gray-700">
Filter by Challenge Tags
</label>
{selectedChallengeTags.length > 0 && (
<button
onClick={() => setSelectedChallengeTags([])}
className="px-3 py-1.5 text-sm font-medium rounded-full transition-colors bg-gray-100 text-gray-700 hover:bg-gray-200"
>
Clear Tags
</button>
)}
</div>
<div className="flex flex-wrap gap-2">
{challengeTags.map((tag) => {
const isSelected = selectedChallengeTags.includes(tag.name);
const hasValidColor = tag.color && tag.color !== '#ffffff' && tag.color !== '#fff';

return (
<button
key={tag.id}
onClick={() => {
if (isSelected) {
setSelectedChallengeTags(selectedChallengeTags.filter(t => t !== tag.name));
} else {
setSelectedChallengeTags([...selectedChallengeTags, tag.name]);
}
}}
className={`px-3 py-1.5 text-sm font-medium rounded-full transition-colors ${
isSelected
? hasValidColor
? `text-white border-2 border-gray-300`
: 'bg-blue-600 text-white'
: hasValidColor
? `text-gray-800 border border-gray-300 hover:bg-gray-100`
: 'bg-blue-100 text-blue-700 hover:bg-blue-200'
}`}
style={isSelected && hasValidColor ? { backgroundColor: tag.color } :
!isSelected && hasValidColor ? { backgroundColor: `${tag.color}20` } : {}}
title={tag.description || undefined}
>
{tag.name} ({tag.projectCount})
</button>
);
})}
</div>
</div>
)}

{/* Results Count */}
<div className="mt-3 pt-3 border-t border-gray-200">
<p className="text-xs text-gray-500">
Expand Down