- {media ? (
+
+ {Array.from({ length: fireIconsToShow }).map((_, i) => (
+
+ ))}
+ {remainingBoosts > 0 && (
+
+ +{remainingBoosts}
+
+ )}
+
+
+ {media ? (
+
- ) : (
-
- {isLink ? (
-
- ) : (
-
- {"\u201C"}
-
- )}
-
-
-
-
- )}
-
-
-
-
-
- {author.handle ? (
- event.stopPropagation()}
- className="tw-truncate tw-text-sm tw-font-medium tw-text-iron-50 tw-no-underline tw-transition-colors desktop-hover:hover:tw-text-white"
- >
- {author.handle}
-
+
+ ) : (
+
+ {startsWithLink ? (
+
) : (
-
- Anonymous
+
+ {"\u201C"}
)}
- event.stopPropagation()}
- className="tw-truncate tw-text-xs tw-text-iron-500 tw-no-underline tw-transition-colors desktop-hover:hover:tw-text-iron-300"
- >
- {waveName}
-
+
+ )}
+
+
+ {/* Author */}
+
event.stopPropagation()}
+ className="tw-flex tw-items-center tw-gap-2 tw-no-underline tw-transition-opacity desktop-hover:hover:tw-opacity-80"
+ >
+
+
+ {author.handle ?? "Anonymous"}
+
+
+
+ {/* Wave */}
+
event.stopPropagation()}
+ className="tw-group/wave tw-flex tw-items-center tw-gap-2 tw-no-underline tw-transition-all"
+ >
+
{waveName}
+ {wave.picture && (
+

+ )}
+
);
diff --git a/components/home/boosted/BoostedSection.tsx b/components/home/boosted/BoostedSection.tsx
index 49cdbca0c0..ae0ca1f6e3 100644
--- a/components/home/boosted/BoostedSection.tsx
+++ b/components/home/boosted/BoostedSection.tsx
@@ -2,43 +2,43 @@
import type { ApiDrop } from "@/generated/models/ApiDrop";
import { useBoostedDrops } from "@/hooks/useBoostedDrops";
-import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
+import { useMediaQuery } from "@/hooks/useMediaQuery";
import { useRouter } from "next/navigation";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useMemo } from "react";
+import Masonry from "react-masonry-css";
import BoostedDropCardHome from "./BoostedDropCardHome";
const BOOSTED_DROPS_LIMIT = 50;
+const MAX_ROWS = 3;
+
+const MASONRY_BREAKPOINTS = {
+ default: 4,
+ 1280: 3,
+ 1024: 3,
+ 768: 2,
+ 640: 1,
+};
export function BoostedSection() {
const router = useRouter();
- const scrollRef = useRef
(null);
- const [canScrollLeft, setCanScrollLeft] = useState(false);
- const [canScrollRight, setCanScrollRight] = useState(false);
const { data: drops, isLoading } = useBoostedDrops({
limit: BOOSTED_DROPS_LIMIT,
});
- const updateScrollState = useCallback(() => {
- const container = scrollRef.current;
- if (!container) return;
- const maxScrollLeft = container.scrollWidth - container.clientWidth;
- const epsilon = 1;
- setCanScrollLeft(container.scrollLeft > epsilon);
- setCanScrollRight(container.scrollLeft < maxScrollLeft - epsilon);
- }, []);
+ // Detect breakpoints to determine column count
+ const isXl = useMediaQuery("(min-width: 1281px)");
+ const isLg = useMediaQuery("(min-width: 1024px)");
+ const isMd = useMediaQuery("(min-width: 768px)");
+ const isSm = useMediaQuery("(min-width: 640px)");
- useEffect(() => {
- const container = scrollRef.current;
- if (!container) return;
- updateScrollState();
- const handleScroll = () => updateScrollState();
- container.addEventListener("scroll", handleScroll, { passive: true });
- window.addEventListener("resize", updateScrollState);
- return () => {
- container.removeEventListener("scroll", handleScroll);
- window.removeEventListener("resize", updateScrollState);
- };
- }, [updateScrollState, drops?.length]);
+ // Calculate max items based on columns * MAX_ROWS (mobile: 1x6)
+ const visibleDrops = useMemo(() => {
+ if (!drops) return [];
+ if (!isSm) return drops.slice(0, 6); // Mobile: 1 col Ă— 6 items
+ const columns = isXl ? 4 : isLg ? 3 : 2;
+ const maxItems = columns * MAX_ROWS;
+ return drops.slice(0, maxItems);
+ }, [drops, isXl, isLg, isMd, isSm]);
const handleDropClick = useCallback(
(drop: ApiDrop) => {
@@ -47,15 +47,6 @@ export function BoostedSection() {
[router]
);
- const scroll = (direction: "left" | "right") => {
- if (!scrollRef.current) return;
- const scrollAmount = 260;
- scrollRef.current.scrollBy({
- left: direction === "left" ? -scrollAmount : scrollAmount,
- behavior: "smooth",
- });
- };
-
if (isLoading) {
return (
@@ -68,57 +59,36 @@ export function BoostedSection() {
);
}
- if (!drops || drops.length === 0) {
+ if (!drops || visibleDrops.length === 0) {
return null;
}
return (
-
+
-
-
-
- Boosted Drops
-
-
- Community-boosted right now
-
-
-
-
-
-
+
+
+ Boosted Drops
+
+
+ Community-boosted right now
+
- {/* Horizontal scroll container */}
-
- {drops.map((drop) => (
-
handleDropClick(drop)}
- />
+ {visibleDrops.map((drop) => (
+
+ handleDropClick(drop)}
+ />
+
))}
-
+
);
diff --git a/components/home/explore-waves/ExploreWavesSection.tsx b/components/home/explore-waves/ExploreWavesSection.tsx
index 7fc6783e2d..94328765f3 100644
--- a/components/home/explore-waves/ExploreWavesSection.tsx
+++ b/components/home/explore-waves/ExploreWavesSection.tsx
@@ -18,14 +18,10 @@ export function ExploreWavesSection() {
} = useQuery({
queryKey: ["explore-waves-homepage", WAVES_LIMIT],
queryFn: async () => {
- return await commonApiFetch
({
- endpoint: "waves-overview",
- params: {
- type: "RECENTLY_DROPPED_TO",
- limit: String(WAVES_LIMIT),
- offset: "0",
- },
+ const data = await commonApiFetch({
+ endpoint: "waves-overview/hot",
});
+ return data.slice(0, WAVES_LIMIT);
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
@@ -40,16 +36,13 @@ export function ExploreWavesSection() {
}
return (
-
+
- Explore waves
+ Tired of bot replies?
-
- Browse channels—jump into the conversation.
-
+
+ 6529
+
+
+ Building a decentralized network state
+
+
+ Join the most interesting chats in crypto
+
+
+ );
+}
diff --git a/components/home/hero/index.ts b/components/home/hero/index.ts
index f1f4adc1be..34c96a1bf6 100644
--- a/components/home/hero/index.ts
+++ b/components/home/hero/index.ts
@@ -1 +1,2 @@
export { default as CarouselHeader } from "./CarouselHeader";
+export { default as HeroHeader } from "./HeroHeader";
diff --git a/components/home/now-minting/NowMintingCountdownActive.tsx b/components/home/now-minting/NowMintingCountdownActive.tsx
index 8c276731a3..215552870e 100644
--- a/components/home/now-minting/NowMintingCountdownActive.tsx
+++ b/components/home/now-minting/NowMintingCountdownActive.tsx
@@ -12,26 +12,25 @@ export default function NowMintingCountdownActive({
countdown,
}: NowMintingCountdownActiveProps) {
return (
-
-
-
-
+
+
+
{countdown.title}
{countdown.isActive && (
-
-
-
+
@@ -39,7 +38,7 @@ export default function NowMintingCountdownActive({
{countdown.showMintBtn && (
Mint
-
-
-
+
+
+
-
+
Error fetching mint information
diff --git a/components/home/now-minting/NowMintingCountdownFinalized.tsx b/components/home/now-minting/NowMintingCountdownFinalized.tsx
index 340edbb7a9..1e2ddf5346 100644
--- a/components/home/now-minting/NowMintingCountdownFinalized.tsx
+++ b/components/home/now-minting/NowMintingCountdownFinalized.tsx
@@ -2,14 +2,13 @@ import ClockIcon from "@/components/utils/icons/ClockIcon";
export default function NowMintingCountdownFinalized() {
return (
-
-
-
-
+
+
+
-
+
Mint phase complete
diff --git a/components/home/now-minting/NowMintingCountdownLoading.tsx b/components/home/now-minting/NowMintingCountdownLoading.tsx
index 289c61639f..e8a20a9b94 100644
--- a/components/home/now-minting/NowMintingCountdownLoading.tsx
+++ b/components/home/now-minting/NowMintingCountdownLoading.tsx
@@ -1,11 +1,10 @@
export default function NowMintingCountdownLoading() {
return (
-
-
-
+
);
diff --git a/components/home/now-minting/NowMintingCountdownSoldOut.tsx b/components/home/now-minting/NowMintingCountdownSoldOut.tsx
index e503e8a413..99fc012bc6 100644
--- a/components/home/now-minting/NowMintingCountdownSoldOut.tsx
+++ b/components/home/now-minting/NowMintingCountdownSoldOut.tsx
@@ -2,14 +2,13 @@ import { CheckCircleIcon } from "@heroicons/react/24/outline";
export default function NowMintingCountdownSoldOut() {
return (
-
-
-
-
-
+
+
+
+
-
+
Mint complete
diff --git a/components/home/now-minting/NowMintingDetails.tsx b/components/home/now-minting/NowMintingDetails.tsx
index 8e730037ff..eedb503448 100644
--- a/components/home/now-minting/NowMintingDetails.tsx
+++ b/components/home/now-minting/NowMintingDetails.tsx
@@ -17,20 +17,18 @@ export default function NowMintingDetails({ nft }: NowMintingDetailsProps) {
if (value <= 0) return "N/A";
return `${value.toFixed(5)} ETH`;
};
+ const floorPrice = formatEth(nft.floor_price);
return (
-
+
-
+
+
Edition Details
-
+
{details.map(({ label, value }) => (
@@ -45,7 +45,7 @@ export default function NowMintingDetailsAccordion({
Distribution Plan
View
diff --git a/components/home/now-minting/NowMintingHeader.tsx b/components/home/now-minting/NowMintingHeader.tsx
index 8096e331f7..5ecf37b3b6 100644
--- a/components/home/now-minting/NowMintingHeader.tsx
+++ b/components/home/now-minting/NowMintingHeader.tsx
@@ -28,10 +28,6 @@ export default function NowMintingHeader({
return (
-
- Card #{cardNumber}
-
-
-
- {profile?.pfp ? (
-
- ) : (
-
- )}
-
- {hasHandle ? (
-
+
+ Card #{cardNumber}
+
+
+ {profile?.pfp ? (
+
) : (
- artistName
+
)}
+
+ {hasHandle ? (
+
+ ) : (
+ artistName
+ )}
+
diff --git a/components/home/now-minting/NowMintingSection.tsx b/components/home/now-minting/NowMintingSection.tsx
index 762935392f..a18639a622 100644
--- a/components/home/now-minting/NowMintingSection.tsx
+++ b/components/home/now-minting/NowMintingSection.tsx
@@ -9,25 +9,25 @@ export default function NowMintingSection() {
if (isFetching && !nft) {
return (
-
-
+
+
Latest Drop
-
-
-
-
+
+
+
-
-
-
-
-
-
+
+
+
-
+
{...new Array(4).map((_, i) => (
@@ -35,8 +35,8 @@ export default function NowMintingSection() {
))}
-
-
+
+
@@ -50,18 +50,18 @@ export default function NowMintingSection() {
}
return (
-
-
+
+
Latest Drop
-
-
-
+
+
+
-
diff --git a/components/home/now-minting/NowMintingStatsGrid.tsx b/components/home/now-minting/NowMintingStatsGrid.tsx
index c877095421..444650ec8c 100644
--- a/components/home/now-minting/NowMintingStatsGrid.tsx
+++ b/components/home/now-minting/NowMintingStatsGrid.tsx
@@ -25,12 +25,12 @@ export default function NowMintingStatsGrid({
const statusTone = manifoldClaim?.isFinalized ? "ended" : status;
const editionSize = manifoldClaim
- ? formatEditionSize(manifoldClaim)
+ ? formatEditionSize(manifoldClaim).replace(/\s*\/\s*/, "/")
: undefined;
const mintPrice = manifoldClaim ? formatClaimCost(manifoldClaim) : undefined;
return (
-
+
- {label}
+
+ {label}
+
{isLoading ? (
) : (
-
+
{value}
)}
diff --git a/components/waves/drops/ContentSegmentComponent.tsx b/components/waves/drops/ContentSegmentComponent.tsx
index e0290d519b..83f852e2f6 100644
--- a/components/waves/drops/ContentSegmentComponent.tsx
+++ b/components/waves/drops/ContentSegmentComponent.tsx
@@ -6,6 +6,49 @@ interface ContentSegmentComponentProps {
readonly index: number;
}
+const URL_REGEX = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]"'])/g;
+
+function linkifyText(text: string, segmentIndex: number): React.ReactNode[] {
+ const parts: React.ReactNode[] = [];
+ let lastIndex = 0;
+ let match;
+ let partIndex = 0;
+
+ const regex = new RegExp(URL_REGEX.source, "g");
+ while ((match = regex.exec(text)) !== null) {
+ if (match.index > lastIndex) {
+ parts.push(
+
+ {text.slice(lastIndex, match.index)}
+
+ );
+ }
+ parts.push(
+ e.stopPropagation()}
+ className="tw-text-primary-300 hover:tw-text-primary-400 tw-transition tw-duration-300"
+ >
+ {match[0]}
+
+ );
+ lastIndex = regex.lastIndex;
+ }
+
+ if (lastIndex < text.length) {
+ parts.push(
+
+ {text.slice(lastIndex)}
+
+ );
+ }
+
+ return parts.length > 0 ? parts : [text];
+}
+
/**
* Component to render a single segment of content (text or media)
*/
@@ -14,7 +57,7 @@ export default function ContentSegmentComponent({
index,
}: ContentSegmentComponentProps) {
if (segment.type === "text") {
- return {segment.content};
+ return {linkifyText(segment.content, index)};
}
if (segment.mediaInfo) {
diff --git a/components/waves/drops/WaveDropActionsAddReaction.tsx b/components/waves/drops/WaveDropActionsAddReaction.tsx
index e5b1341c85..9cfbc990a1 100644
--- a/components/waves/drops/WaveDropActionsAddReaction.tsx
+++ b/components/waves/drops/WaveDropActionsAddReaction.tsx
@@ -92,6 +92,7 @@ const WaveDropActionsAddReaction: React.FC<{
max_rating: 0,
reaction: null,
boosted: false,
+ bookmarked: false,
};
draft.context_profile_context = {
diff --git a/components/waves/drops/WaveDropReactions.tsx b/components/waves/drops/WaveDropReactions.tsx
index 6d36b8c711..3aa71af1ab 100644
--- a/components/waves/drops/WaveDropReactions.tsx
+++ b/components/waves/drops/WaveDropReactions.tsx
@@ -295,6 +295,7 @@ function WaveDropReaction({
max_rating: 0,
reaction: null,
boosted: false,
+ bookmarked: false,
};
draft.context_profile_context = {
diff --git a/generated/models/ApiDropContextProfileContext.ts b/generated/models/ApiDropContextProfileContext.ts
index b32a0d564e..1c895d25dd 100644
--- a/generated/models/ApiDropContextProfileContext.ts
+++ b/generated/models/ApiDropContextProfileContext.ts
@@ -19,6 +19,7 @@ export class ApiDropContextProfileContext {
'max_rating': number;
'reaction': string | null;
'boosted': boolean;
+ 'bookmarked': boolean;
static readonly discriminator: string | undefined = undefined;
@@ -54,6 +55,12 @@ export class ApiDropContextProfileContext {
"baseName": "boosted",
"type": "boolean",
"format": ""
+ },
+ {
+ "name": "bookmarked",
+ "baseName": "bookmarked",
+ "type": "boolean",
+ "format": ""
} ];
static getAttributeTypeMap() {
diff --git a/hooks/drops/useDropBoostMutation.ts b/hooks/drops/useDropBoostMutation.ts
index fc319aa75e..86a3a3f50e 100644
--- a/hooks/drops/useDropBoostMutation.ts
+++ b/hooks/drops/useDropBoostMutation.ts
@@ -63,6 +63,7 @@ export const useDropBoostMutation = (): UseDropBoostMutationReturn => {
max_rating: 0,
reaction: null,
boosted: false,
+ bookmarked: false,
};
draft.context_profile_context = {
diff --git a/openapi.yaml b/openapi.yaml
index e56994b5a4..d5e42c9b3f 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -1050,6 +1050,95 @@ paths:
description: successful operation
"404":
description: Drop not found
+ /drops/{dropId}/bookmark:
+ post:
+ tags:
+ - Drops
+ summary: Bookmark a drop
+ description: Requires the user to be authenticated
+ operationId: bookmarkDrop
+ parameters:
+ - name: dropId
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: successful operation
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ApiDrop"
+ "400":
+ description: Invalid request
+ "404":
+ description: Drop not found
+ delete:
+ tags:
+ - Drops
+ summary: Remove bookmark from drop
+ description: Requires the user to be authenticated
+ operationId: unbookmarkDrop
+ parameters:
+ - name: dropId
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: successful operation
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ApiDrop"
+ "404":
+ description: Drop not found
+ /drops-bookmarked/:
+ get:
+ tags:
+ - Drops
+ summary: Get bookmarked drops for authenticated user
+ description: >-
+ Requires the user to be authenticated. Returns drops bookmarked by the
+ current user, sorted by bookmark time.
+ operationId: getBookmarkedDrops
+ parameters:
+ - name: wave_id
+ in: query
+ description: Filter by wave
+ required: false
+ schema:
+ type: string
+ - name: page
+ in: query
+ required: false
+ schema:
+ type: integer
+ format: int64
+ minimum: 1
+ - name: page_size
+ in: query
+ required: false
+ schema:
+ type: integer
+ format: int64
+ minimum: 1
+ maximum: 200
+ - name: sort_direction
+ in: query
+ description: Default is DESC (newest bookmarks first)
+ required: false
+ schema:
+ $ref: "#/components/schemas/ApiPageSortDirection"
+ responses:
+ "200":
+ description: successful operation
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ApiDropsPage"
/drops/{dropId}:
get:
tags:
@@ -4145,6 +4234,22 @@ paths:
type: array
items:
$ref: "#/components/schemas/ApiWave"
+ /waves-overview/hot:
+ get:
+ tags:
+ - Waves
+ summary: Get hot waves overview.
+ description: Returns up to 25 public waves ranked by activity in the last 24 hours.
+ operationId: getHotWavesOverview
+ responses:
+ "200":
+ description: successful operation
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: "#/components/schemas/ApiWave"
/waves:
get:
tags:
@@ -6853,6 +6958,7 @@ components:
- max_rating
- reaction
- boosted
+ - bookmarked
properties:
rating:
type: number
@@ -6868,6 +6974,8 @@ components:
nullable: true
boosted:
type: boolean
+ bookmarked:
+ type: boolean
ApiDropMedia:
type: object
required:
diff --git a/package-lock.json b/package-lock.json
index def78b3550..2a7f552b75 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -89,6 +89,7 @@
"react-dom": "^19.2.3",
"react-error-boundary": "^6.0.0",
"react-markdown": "^9.0.1",
+ "react-masonry-css": "^1.0.16",
"react-redux": "^9.1.2",
"react-scroll": "^1.9.0",
"react-toastify": "^10.0.5",
@@ -26914,6 +26915,15 @@
"react": ">=18"
}
},
+ "node_modules/react-masonry-css": {
+ "version": "1.0.16",
+ "resolved": "https://registry.npmjs.org/react-masonry-css/-/react-masonry-css-1.0.16.tgz",
+ "integrity": "sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=16.0.0"
+ }
+ },
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
diff --git a/package.json b/package.json
index 17d489426a..54a26bb372 100644
--- a/package.json
+++ b/package.json
@@ -123,6 +123,7 @@
"react-dom": "^19.2.3",
"react-error-boundary": "^6.0.0",
"react-markdown": "^9.0.1",
+ "react-masonry-css": "^1.0.16",
"react-redux": "^9.1.2",
"react-scroll": "^1.9.0",
"react-toastify": "^10.0.5",
diff --git a/scripts/worktree/wt-common.sh b/scripts/worktree/wt-common.sh
index 6bae8a0944..4c8fc1506f 100644
--- a/scripts/worktree/wt-common.sh
+++ b/scripts/worktree/wt-common.sh
@@ -9,20 +9,27 @@ setup_vscode_settings() {
local settings_file="$vscode_path/settings.json"
# Keep existing color if present to avoid changing themes on sync runs.
+ local colors=("red" "orange" "yellow" "green" "blue" "purple" "pink" "teal")
local existing_color=""
if [[ -f "$settings_file" ]]; then
existing_color=$(grep -m1 '"titleBar.activeBackground"' "$settings_file" 2>/dev/null | sed -E 's/.*"titleBar.activeBackground"\s*:\s*"([^"]+)".*/\1/') || true
fi
-
- local colors=("red" "orange" "yellow" "green" "blue" "purple" "pink" "teal")
- local color=${existing_color:-${colors[$RANDOM % ${#colors[@]}]}}
+ # Validate extracted color is one of our known colors
+ local valid_color=""
+ for c in "${colors[@]}"; do
+ if [[ "$existing_color" == "$c" ]]; then
+ valid_color="$existing_color"
+ break
+ fi
+ done
+ local color=${valid_color:-${colors[$RANDOM % ${#colors[@]}]}}
mkdir -p "$vscode_path"
- cat > "$settings_file" < "$settings_file" <<'EOF'
{
"workbench.colorCustomizations": {
- "titleBar.activeBackground": "$color",
- "titleBar.inactiveBackground": "$color"
+ "titleBar.activeBackground": "__COLOR__",
+ "titleBar.inactiveBackground": "__COLOR__"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
@@ -87,12 +94,13 @@ setup_vscode_settings() {
// --- Tailwind IntelliSense (if using the extension) ---
"tailwindCSS.experimental.classRegex": [
"['\"`]([^'\"`]*tw-[^'\"`]*)['\"`]",
- ["clsx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] ,
- ["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] ,
+ ["clsx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"],
+ ["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"],
["cva\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
]
}
EOF
+ sed -i '' "s/__COLOR__/$color/g" "$settings_file"
printf "%s" "$color"
}