Skip to content
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
45 changes: 39 additions & 6 deletions src/components/detail/DetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useRef } from "react";
import {
useRedeemExpiredTokens,
useRedeemTokens,
Expand All @@ -18,7 +18,12 @@ import {
import { generateMockPriceHistory } from "../../utils-react/mock-price-history";
import MarketChart from "../chart/MarketChart";
import { CommentsSection } from "./comments/CommentsSection";
import { MarketHeaderBottom, MarketHeaderTop } from "./MarketHeader";
import {
MarketHeaderBottom,
MarketHeaderTop,
StickyMarketHeader,
useIsInView,
} from "./MarketHeader";
import TradingPanel from "./TradingPanel";

export default function DetailPage() {
Expand Down Expand Up @@ -76,6 +81,19 @@ export default function DetailPage() {
[market],
);

// Hooks must run on every render path; declared before the
// not-found early-return so React's hook order stays stable.
// `titleVisibility.ref` attaches to the wrapper around
// MarketHeaderTop so the sticky minimized bar appears once the
// full-size H1 starts intruding into the bar's reserved area
// at the top of the viewport (negative top rootMargin covers
// the macOS strip + bar height). `leftColumnRef` gives the
// fixed-positioned bar a column-width to track.
const titleVisibility = useIsInView<HTMLDivElement>({
rootMargin: "-80px 0px 0px 0px",
});
const leftColumnRef = useRef<HTMLElement>(null);

if (!market) {
return (
<div className="phi-container py-16 text-center">
Expand Down Expand Up @@ -120,13 +138,18 @@ export default function DetailPage() {
</div>
)}

{/* Header above grid — matches group market layout so trading panel
aligns with the chart rather than the top of the title block */}
<MarketHeaderTop market={market} />
{/* Header above grid — matches group market layout so trading
panel aligns with the chart rather than the top of the
title block. The ref/observer feeds the sticky bar's
visibility below; the bar mounts only when this wrapper
has scrolled out of view. */}
<div ref={titleVisibility.ref}>
<MarketHeaderTop market={market} />
</div>

<div className="grid gap-8 lg:grid-cols-[1.618fr_0.8fr]">
{/* Left column */}
<section className="min-w-0 space-y-6">
<section ref={leftColumnRef} className="min-w-0 space-y-6">
<MarketChart
market={market}
priceHistory={priceHistory}
Expand Down Expand Up @@ -217,6 +240,16 @@ export default function DetailPage() {
<TradingPanel market={market} />
</div>
</div>

{/* Fixed-positioned, so its location in the tree doesn't
affect layout. Mounted only when the full-size H1 has
scrolled out of view; column-rect tracking keeps the
bar's left+width aligned with the left content column. */}
<StickyMarketHeader
market={market}
visible={!titleVisibility.inView}
columnRef={leftColumnRef}
/>
</div>
);
}
282 changes: 281 additions & 1 deletion src/components/detail/MarketHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useStore } from "../../store";
import type { Market } from "../../types";
import type { Market, MarketGroup } from "../../types";
import {
formatSettlementDateTime,
formatTimeRemaining,
Expand All @@ -8,6 +9,7 @@ import {
getEstimatedSettlementDate,
getPositionContracts,
} from "../../utils-react/market";
import { OUTCOME_COLORS } from "../group/GroupChart";
import { categoryIcon } from "../layout/TopShell";
import { MarketActionsMenu } from "./MarketActionsMenu";

Expand Down Expand Up @@ -160,3 +162,281 @@ export default function MarketHeader({ market }: { market: Market }) {
</>
);
}

/** Hook + ref pair to detect when an element scrolls out of the
* viewport. Used by the detail page to know when the full-size
* H1 has scrolled past the top so it can render the minimized
* sticky bar.
*
* `rootMargin` shifts the intersection root from the actual
* viewport. The detail page passes a negative top margin so the
* bar trips a little earlier — as soon as the title intrudes
* into the bar's reserved area at the top of the viewport,
* rather than only after the title is fully scrolled past.
*
* Default `true` so the first-paint state is "title visible,
* bar hidden" until the observer fires. */
export function useIsInView<T extends HTMLElement>(
options: { rootMargin?: string } = {},
): {
ref: React.RefObject<T | null>;
inView: boolean;
} {
const { rootMargin } = options;
const ref = useRef<T>(null);
const [inView, setInView] = useState(true);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => setInView(entry?.isIntersecting ?? false),
{ threshold: 0, rootMargin },
);
observer.observe(el);
return () => observer.disconnect();
}, [rootMargin]);
return { ref, inView };
}

/** Pixels the sticky bar's banner extends past the column on each
* side. 16px = half of the 32px (`gap-8`) inter-column gutter, so
* the right edge lands at the midpoint of the gap and visually
* doesn't crowd the right-column trading panel. The same
* symmetric overhang on the left keeps the bar centered around
* its content. Compensated with matching extra horizontal
* padding so the bar's content (back arrow, title, pills) stays
* aligned with the column — only the background / border /
* rounded chrome reaches out further. */
const STICKY_BAR_BG_OVERHANG = 16;
/** Built-in horizontal padding the bar's content uses inside the
* banner — the original `px-3` Tailwind utility's value. Kept as a
* constant so the inline-style padding math stays readable. */
const STICKY_BAR_INNER_PADDING_X = 12;

/** Track an element's bounding-rect left+width so a `position:
* fixed` bar can match the host column's horizontal position.
* Updates on element resize, page scroll (scrollbar appearance
* shifts horizontal layout on some setups), and window resize.
* Returns null until the ref attaches, so the consumer can
* short-circuit before its first paint and avoid flashing at the
* wrong x-coordinate. */
function useElementRect<T extends HTMLElement>(
ref: React.RefObject<T | null>,
): { left: number; width: number } | null {
const [rect, setRect] = useState<{ left: number; width: number } | null>(
null,
);
useEffect(() => {
const el = ref.current;
if (!el) return;
const update = () => {
const r = el.getBoundingClientRect();
setRect({ left: r.left, width: r.width });
};
update();
const observer = new ResizeObserver(update);
observer.observe(el);
window.addEventListener("resize", update);
return () => {
observer.disconnect();
window.removeEventListener("resize", update);
};
}, [ref]);
return rect;
}

/**
* Compact title bar that appears at the top of the viewport once
* the full-size H1 has scrolled past — keeps "what am I commenting
* on" visible while the user scrolls through the chart, resolution
* info, and comment list.
*
* Uses `position: fixed` (not `sticky`) so it's completely absent
* from the layout when the title is in view — no leading row of
* empty space, no flicker when scrolling past. `columnRef` is the
* left grid column's bounding box so the bar's left+width match
* that column and don't bleed across the right-column trading
* panel.
*/
export function StickyMarketHeader({
market,
visible,
columnRef,
}: {
market: Market;
visible: boolean;
columnRef: React.RefObject<HTMLElement | null>;
}) {
const yesPct =
market.yesPrice != null ? Math.round(market.yesPrice * 100) : null;
const noPct = yesPct != null ? 100 - yesPct : null;
const rect = useElementRect(columnRef);
if (!visible || rect == null) return null;
return (
<div
// `position: fixed` with computed left/width keeps the bar
// aligned to the column without depending on layout flow.
// `.sticky-overlay-safe-top` provides top: 0 + the macOS
// padding that clears the traffic-light strip while letting
// the bar's bg fill the strip area.
// The banner extends `STICKY_BAR_BG_OVERHANG` past the column
// on each side (background + border + corners only). Inner
// horizontal padding compensates so the back arrow / title /
// pills sit at the same x-coords as the column edges — the
// overhang is purely a chrome flourish.
style={{
left: rect.left - STICKY_BAR_BG_OVERHANG,
width: rect.width + STICKY_BAR_BG_OVERHANG * 2,
paddingLeft: STICKY_BAR_BG_OVERHANG + STICKY_BAR_INNER_PADDING_X,
paddingRight: STICKY_BAR_BG_OVERHANG + STICKY_BAR_INNER_PADDING_X,
}}
className="sticky-overlay-safe-top fixed z-20 flex items-center gap-3 rounded-lg border border-slate-800/60 bg-slate-950/65 backdrop-blur"
>
<button
type="button"
onClick={() => useStore.setState({ view: "home" })}
title="Back to markets"
className="shrink-0 text-slate-500 transition hover:text-slate-200"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="h-4 w-4"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<p className="min-w-0 flex-1 truncate text-base font-medium text-slate-100">
{market.question}
</p>
<div className="flex shrink-0 items-center gap-1.5 text-xs">
{yesPct != null && (
<span className="rounded-full bg-emerald-500/15 px-2.5 py-1 font-medium text-emerald-300">
Yes {yesPct}%
</span>
)}
{noPct != null && (
<span className="rounded-full bg-rose-500/15 px-2.5 py-1 font-medium text-rose-300">
No {noPct}%
</span>
)}
</div>
</div>
);
}

/**
* Multi-outcome counterpart to `StickyMarketHeader`. Same chrome,
* same sticky / overlay-safe positioning rules. The right side
* shows the currently-selected outcome — falling back to the
* leading outcome (highest yesPrice) when none is explicitly
* selected — plus a `+N` badge for the rest. Tracking the
* selection means the bar stays in sync with whichever outcome
* the user is reading / trading on the right column.
*/
export function StickyGroupHeader({
group,
selectedOutcomeId,
visible,
columnRef,
}: {
group: MarketGroup;
selectedOutcomeId: string | null;
visible: boolean;
columnRef: React.RefObject<HTMLElement | null>;
}) {
// Resolve the featured outcome via index lookup (not just `find`)
// because the index drives the palette color — the rest of the
// group UI (chart legend, outcome list, trading panel header)
// ties an outcome to `OUTCOME_COLORS[index % palette.length]`,
// and the pill should match so the user reads the bar as the
// same entity they're trading on.
const selectedIndex = group.outcomes.findIndex(
(o) => o.id === selectedOutcomeId,
);
const fallbackIndex = group.outcomes.reduce(
(best, o, i, arr) => (o.yesPrice > arr[best].yesPrice ? i : best),
0,
);
const featuredIndex =
selectedIndex >= 0
? selectedIndex
: group.outcomes.length > 0
? fallbackIndex
: -1;
const featured = featuredIndex >= 0 ? group.outcomes[featuredIndex] : null;
const featuredColor =
featuredIndex >= 0
? OUTCOME_COLORS[featuredIndex % OUTCOME_COLORS.length]
: null;
const featuredPct =
featured != null ? Math.round(featured.yesPrice * 100) : null;
const otherCount = Math.max(group.outcomes.length - 1, 0);
const rect = useElementRect(columnRef);
if (!visible || rect == null) return null;
return (
<div
// Same `position: fixed` + column-rect alignment + banner
// overhang as `StickyMarketHeader` — see that component for
// the rationale.
style={{
left: rect.left - STICKY_BAR_BG_OVERHANG,
width: rect.width + STICKY_BAR_BG_OVERHANG * 2,
paddingLeft: STICKY_BAR_BG_OVERHANG + STICKY_BAR_INNER_PADDING_X,
paddingRight: STICKY_BAR_BG_OVERHANG + STICKY_BAR_INNER_PADDING_X,
}}
className="sticky-overlay-safe-top fixed z-20 flex items-center gap-3 rounded-lg border border-slate-800/60 bg-slate-950/65 backdrop-blur"
>
<button
type="button"
onClick={() => useStore.setState({ view: "home" })}
title="Back to markets"
className="shrink-0 text-slate-500 transition hover:text-slate-200"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="h-4 w-4"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<p className="min-w-0 flex-1 truncate text-base font-medium text-slate-100">
{group.title}
</p>
{featured != null && featuredPct != null && featuredColor != null && (
<div className="flex shrink-0 items-center gap-1.5 text-xs">
{/* Inline style instead of a Tailwind utility because the
palette is an array of arbitrary hex values, not a
fixed Tailwind token set. The `1f` suffix on the
background hex is ~12% alpha — keeps the pill subtle
while letting the outcome's accent read at a glance. */}
<span
className="max-w-[160px] truncate rounded-full px-2.5 py-1 font-medium"
style={{
backgroundColor: `${featuredColor}1f`,
color: featuredColor,
}}
>
{featured.name} {featuredPct}%
</span>
{otherCount > 0 && (
<span className="rounded-full bg-slate-800/60 px-2.5 py-1 font-medium text-slate-400">
+{otherCount}
</span>
)}
</div>
)}
</div>
);
}
2 changes: 1 addition & 1 deletion src/components/detail/comments/CommentProfileDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ export function CommentProfileDialog({
>
<polygon
// Verified → purple check badge (NIP-05 conventional
// colour). Unverified / unknown → neutral grey so
// color). Unverified / unknown → neutral grey so
// transient CORS/network errors don't surface as a
// scary warning.
fill={nip05Verified === true ? "#a855f7" : "#475569"}
Expand Down
2 changes: 1 addition & 1 deletion src/components/group/GroupChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from "../../utils-react/chart-series";
import { formatVolumeBtc } from "../../utils-react/format";

// ── Palette — 14 perceptually distinct colours ───────────────────────
// ── Palette — 14 perceptually distinct colors ───────────────────────
export const OUTCOME_COLORS = [
"#34d399", // emerald-400
"#60a5fa", // blue-400
Expand Down
Loading
Loading