Skip to content

Commit

Permalink
Content manager & featured listings (#526)
Browse files Browse the repository at this point in the history
* Content manager & featured listings

* content manager role and feature post

* Frontend lint

* Updates

* Linting

* Unit test

* Additional code updates
  • Loading branch information
ivntsng authored Nov 6, 2024
1 parent 69b0efc commit 2887be0
Show file tree
Hide file tree
Showing 16 changed files with 758 additions and 113 deletions.
99 changes: 51 additions & 48 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import PendoInitializer from "@/components/PendoInitializer";
import { ScrollToTop } from "@/components/ScrollToTop";
import SprigInitializer from "@/components/SprigInitializer";
import Footer from "@/components/footer/Footer";
import { FeaturedListingsProvider } from "@/components/listing/FeaturedListings";
import Navbar from "@/components/nav/Navbar";
import APIKeys from "@/components/pages/APIKeys";
import About from "@/components/pages/About";
Expand Down Expand Up @@ -40,62 +41,64 @@ const App = () => {
return (
<Router>
<AuthenticationProvider>
<AlertQueueProvider>
<AlertQueue>
<ScrollToTop>
<div className="flex flex-col bg-gray-1 text-gray-12 min-h-screen">
<Navbar />
<GDPRBanner />
<PendoInitializer />
<SprigInitializer />
<div className="flex-grow">
<Container>
<Routes>
<Route path="/" element={<Home />} />
<FeaturedListingsProvider>
<AlertQueueProvider>
<AlertQueue>
<ScrollToTop>
<div className="flex flex-col bg-gray-1 text-gray-12 min-h-screen">
<Navbar />
<GDPRBanner />
<PendoInitializer />
<SprigInitializer />
<div className="flex-grow">
<Container>
<Routes>
<Route path="/" element={<Home />} />

<Route path="/playground" element={<Playground />} />
<Route path="/playground" element={<Playground />} />

<Route path="/about" element={<About />} />
<Route path="/downloads" element={<DownloadsPage />} />
<Route path="/research" element={<ResearchPage />} />
<Route path="/browse/:page?" element={<Browse />} />
<Route
path="/file/:artifactId"
element={<FileBrowser />}
/>
<Route path="/account" element={<Account />} />
<Route path="/login" element={<Login />} />
<Route path="/logout" element={<Logout />} />
<Route path="/signup/" element={<Signup />} />
<Route path="/signup/:id" element={<EmailSignup />} />
<Route path="/about" element={<About />} />
<Route path="/downloads" element={<DownloadsPage />} />
<Route path="/research" element={<ResearchPage />} />
<Route path="/browse/:page?" element={<Browse />} />
<Route
path="/file/:artifactId"
element={<FileBrowser />}
/>
<Route path="/account" element={<Account />} />
<Route path="/login" element={<Login />} />
<Route path="/logout" element={<Logout />} />
<Route path="/signup/" element={<Signup />} />
<Route path="/signup/:id" element={<EmailSignup />} />

<Route path="/create" element={<Create />} />
<Route
path="/item/:username/:slug"
element={<Listing />}
/>
<Route path="/keys" element={<APIKeys />} />
<Route path="/profile/:id?" element={<Profile />} />
<Route path="/create" element={<Create />} />
<Route
path="/item/:username/:slug"
element={<Listing />}
/>
<Route path="/keys" element={<APIKeys />} />
<Route path="/profile/:id?" element={<Profile />} />

<Route path="/tos" element={<TermsOfService />} />
<Route path="/privacy" element={<PrivacyPolicy />} />
<Route path="/tos" element={<TermsOfService />} />
<Route path="/privacy" element={<PrivacyPolicy />} />

<Route path="/success" element={<OrderSuccess />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="/success" element={<OrderSuccess />} />
<Route path="/orders" element={<OrdersPage />} />

<Route path="/terminal" element={<Terminal />} />
<Route path="/terminal/:id" element={<Terminal />} />
<Route path="/terminal" element={<Terminal />} />
<Route path="/terminal/:id" element={<Terminal />} />

<Route path="/404" element={<NotFound />} />
<Route path="*" element={<NotFoundRedirect />} />
</Routes>
</Container>
<Route path="/404" element={<NotFound />} />
<Route path="*" element={<NotFoundRedirect />} />
</Routes>
</Container>
</div>
<Footer />
</div>
<Footer />
</div>
</ScrollToTop>
</AlertQueue>
</AlertQueueProvider>
</ScrollToTop>
</AlertQueue>
</AlertQueueProvider>
</FeaturedListingsProvider>
</AuthenticationProvider>
</Router>
);
Expand Down
100 changes: 100 additions & 0 deletions frontend/src/components/listing/FeaturedListings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import React, { createContext, useContext, useEffect, useState } from "react";

import { useAuthentication } from "@/hooks/useAuth";

type FeaturedListing = {
id: string;
username: string;
slug: string | null;
name: string;
};

type FeaturedListingsContextType = {
featuredListings: FeaturedListing[];
refreshFeaturedListings: () => Promise<void>;
};

const FeaturedListingsContext =
createContext<FeaturedListingsContextType | null>(null);

export const FeaturedListingsProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const [featuredListings, setFeaturedListings] = useState<FeaturedListing[]>(
[],
);
const auth = useAuthentication();

const refreshFeaturedListings = async () => {
try {
const { data: featuredData } =
await auth.client.GET("/listings/featured");

if (!featuredData?.listing_ids?.length) {
setFeaturedListings([]);
return;
}

const { data: batchData } = await auth.client.GET("/listings/batch", {
params: {
query: { ids: featuredData.listing_ids },
},
});

if (batchData?.listings) {
const orderedListings = featuredData.listing_ids
.map((id) => batchData.listings.find((listing) => listing.id === id))
.filter(
(listing): listing is NonNullable<typeof listing> =>
listing !== undefined,
)
.map((listing) => ({
id: listing.id,
username: listing.username ?? "",
slug: listing.slug,
name: listing.name,
}));

setFeaturedListings(orderedListings);
}
} catch (error) {
console.error("Error refreshing featured listings:", error);
}
};

useEffect(() => {
refreshFeaturedListings();

const handleFeaturedChange = () => {
refreshFeaturedListings();
};

window.addEventListener("featuredListingsChanged", handleFeaturedChange);
return () => {
window.removeEventListener(
"featuredListingsChanged",
handleFeaturedChange,
);
};
}, []);

return (
<FeaturedListingsContext.Provider
value={{ featuredListings, refreshFeaturedListings }}
>
{children}
</FeaturedListingsContext.Provider>
);
};

export const useFeaturedListings = () => {
const context = useContext(FeaturedListingsContext);
if (!context) {
throw new Error(
"useFeaturedListings must be used within a FeaturedListingsProvider",
);
}
return context;
};
2 changes: 1 addition & 1 deletion frontend/src/components/listing/ListingDeleteButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const ListingDeleteButton = (props: Props) => {
onClick={() => setConfirmDelete(true)}
variant={deleting ? "ghost" : "destructive"}
disabled={deleting}
className="flex items-center space-x-2 px-3 py-1 rounded-lg transition-all duration-300 hover:bg-red-600 hover:text-white"
className="flex items-center space-x-2 !px-3 !py-1 !rounded-lg transition-all duration-300 bg-red-500 hover:bg-red-600 text-white"
>
<FaTrash className="text-lg" />
<span>{deleting ? "Deleting..." : "Delete Listing"}</span>
Expand Down
97 changes: 97 additions & 0 deletions frontend/src/components/listing/ListingFeatureButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { useEffect, useState } from "react";
import { FaStar } from "react-icons/fa";

import { useFeaturedListings } from "@/components/listing/FeaturedListings";
import { Button } from "@/components/ui/button";
import { useAlertQueue } from "@/hooks/useAlertQueue";
import { useAuthentication } from "@/hooks/useAuth";

interface Props {
listingId: string;
initialFeatured: boolean;
currentFeaturedCount?: number;
}

const ListingFeatureButton = (props: Props) => {
const { listingId, initialFeatured, currentFeaturedCount = 0 } = props;
const [isFeatured, setIsFeatured] = useState(initialFeatured);
const [isUpdating, setIsUpdating] = useState(false);

const { addAlert, addErrorAlert } = useAlertQueue();
const auth = useAuthentication();
const { refreshFeaturedListings } = useFeaturedListings();

const hasPermission = auth.currentUser?.permissions?.some(
(permission) =>
permission === "is_content_manager" || permission === "is_admin",
);

if (!hasPermission) {
return null;
}

useEffect(() => {
setIsFeatured(initialFeatured);
}, [initialFeatured]);

const handleFeatureToggle = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();

setIsUpdating(true);

try {
const response = await auth.client.PUT(
"/listings/featured/{listing_id}",
{
params: {
path: { listing_id: listingId },
query: { featured: !isFeatured },
},
},
);

if (response.error) {
addErrorAlert(response.error);
} else {
const newFeaturedState = !isFeatured;
setIsFeatured(newFeaturedState);
addAlert(
`Listing ${newFeaturedState ? "featured" : "unfeatured"} successfully`,
"success",
);

refreshFeaturedListings();
}
} catch {
addErrorAlert("Failed to update featured status");
} finally {
setIsUpdating(false);
}
};

return (
<div className="flex items-center gap-2 mt-2">
<Button
onClick={handleFeatureToggle}
variant="primary"
disabled={isUpdating}
title={
currentFeaturedCount >= 3 && !isFeatured
? "Maximum of 3 featured listings allowed. Unfeature another listing first."
: isFeatured
? "Remove from featured"
: "Add to featured"
}
className="flex items-center"
>
<FaStar className="mr-2 h-4 w-4" />
<span className="mr-2">
{isUpdating ? "Updating..." : isFeatured ? "Unfeature" : "Feature"}
</span>
</Button>
</div>
);
};

export default ListingFeatureButton;
12 changes: 10 additions & 2 deletions frontend/src/components/listing/ListingRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from "react";

import ListingDescription from "@/components/listing/ListingDescription";
import ListingFeatureButton from "@/components/listing/ListingFeatureButton";
import ListingFileUpload from "@/components/listing/ListingFileUpload";
import ListingImageFlipper from "@/components/listing/ListingImageFlipper";
import ListingImageGallery from "@/components/listing/ListingImageGallery";
Expand All @@ -25,6 +26,7 @@ const ListingRenderer = ({ listing }: { listing: ListingResponse }) => {
can_edit: canEdit,
user_vote: userVote,
onshape_url: onshapeUrl,
is_featured: isFeatured,
} = listing;
const [artifacts, setArtifacts] = useState(initialArtifacts);
const [currentImageIndex, setCurrentImageIndex] = useState(0);
Expand Down Expand Up @@ -59,7 +61,7 @@ const ListingRenderer = ({ listing }: { listing: ListingResponse }) => {
<ListingMetadata
listingId={listingId}
listingSlug={slug}
creatorId={creatorId}
creatorId={creatorId || ""}
creatorName={creatorName}
creatorUsername={creatorUsername}
views={views}
Expand All @@ -70,7 +72,13 @@ const ListingRenderer = ({ listing }: { listing: ListingResponse }) => {
<hr className="border-gray-200 my-4" />

{/* Build this robot */}
<ListingRegisterRobot listingId={listingId} />
<div className="flex items-center gap-4">
<ListingRegisterRobot listingId={listingId} />
<ListingFeatureButton
listingId={listingId}
initialFeatured={isFeatured}
/>
</div>

<hr className="border-gray-200 my-4" />

Expand Down
Loading

0 comments on commit 2887be0

Please sign in to comment.