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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
# Local-only — never commit.
/attachments/

# Pasted/dropped image scratch — local-only, never commit.
/dropped-images/

# Logs
logs
*.log
Expand Down
Binary file added public/assets/library/library-wide.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions public/library/images/icons/all.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 41 additions & 1 deletion src/components/library/atoms/Icon/Icon.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import classNames from 'classnames';
import type { JSX } from 'react';
import { useEffect } from 'react';

import { IconName, IconProps } from './Icon.types';

Expand Down Expand Up @@ -29,6 +30,41 @@ const ICON_VIEWBOX: Record<IconName, string> = {
[IconName.Copy]: '0 0 16 16',
};

const SPRITE_URL = '/library/images/icons/all.svg';
const SPRITE_DOM_ID = 'library-icon-sprite';

let spritePromise: Promise<void> | null = null;

// Safari (and some WebViews) ignore external `<use href="file.svg#id">`, so the
// sprite never resolves and every icon renders blank there. Fetch the sprite
// once and inline it into the document, then reference symbols same-document via
// `<use href="#id">`, which every browser supports.
function ensureSprite() {
if (typeof document === 'undefined' || spritePromise) {
return;
}
if (document.getElementById(SPRITE_DOM_ID)) {
return;
}
spritePromise = fetch(SPRITE_URL)
.then(res => res.text())
.then(markup => {
if (document.getElementById(SPRITE_DOM_ID)) {
return;
}
const container = document.createElement('div');
container.id = SPRITE_DOM_ID;
container.setAttribute('aria-hidden', 'true');
container.style.cssText =
'position:absolute;width:0;height:0;overflow:hidden';
container.innerHTML = markup;
document.body.prepend(container);
})
.catch(() => {
spritePromise = null;
});
}

export function Icon(props: IconProps): JSX.Element {
const {
width = 40,
Expand All @@ -38,6 +74,10 @@ export function Icon(props: IconProps): JSX.Element {
name,
} = props;

useEffect(() => {
ensureSprite();
}, []);

return (
<svg
className={classNames(styles.icon, className)}
Expand All @@ -48,7 +88,7 @@ export function Icon(props: IconProps): JSX.Element {
viewBox={ICON_VIEWBOX[name]}
role="graphics-document"
>
<use href={`/library/images/icons/all.svg#${name}`} />
<use href={`#${name}`} />
</svg>
);
}
3 changes: 3 additions & 0 deletions src/components/library/molecules/Object/Object.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@

.icon {
display: flex;
// Icons are currentColor sprites; the cover card recolors them white. Here,
// on light cards/sidebar, pin a dark tone so they don't inherit white.
color: var(--gray-darkest);
background: transparent;
border-radius: 8px 0 0 8px;
padding: 3px 4px 3px 0;
Expand Down
10 changes: 7 additions & 3 deletions src/components/library/molecules/RatingBox/RatingBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,13 @@ function ColoredSelect<T extends string | number>(
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);

// Keep the portaled menu glued to the trigger as the modal/page scrolls, and
// flip it above the trigger when it would overflow the bottom of the viewport.
const menuPos = useAnchoredPosition(triggerRef, isOpen, menuRef);
// Keep the portaled menu glued to the trigger as the modal/page scrolls.
// Placement is decided by viewport width, not measured space: open upward at
// 1920px and below, downward only on wider screens. Width-based placement is
// settled before the menu paints, so it never opens one way then jumps.
const menuPos = useAnchoredPosition(triggerRef, isOpen, menuRef, {
openUpMaxWidth: 1920,
});

const handleToggle = () => {
if (readOnly) return;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
// Coordinates in coverHotspots.ts are percentages of the cover frame, so the
// frame is locked to the artwork's native 1536x1024 (3:2) ratio and every
// hotspot tracks the art as it scales. No runtime measurement needed.
// frame is locked to the artwork's native aspect ratio and every hotspot tracks
// the art as it scales. No runtime measurement needed.
//
// Mobile (<768px) shows the taller 3:2 library.png; 768px+ shows the wide
// library-wide.png (3840x1704). The frame matches whichever art is visible so
// the cover always fills the width edge-to-edge with no crop and the height
// stays proportional across every desktop/tablet width.
$cover-aspect: calc(3 / 2);
$cover-aspect-wide: calc(3840 / 1704);

.frame {
position: relative;
Expand All @@ -10,7 +16,17 @@ $cover-aspect: calc(3 / 2);
overflow: hidden;
}

@media (min-width: 768px) {
.frame {
aspect-ratio: $cover-aspect-wide;
}
}

.image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
Expand Down Expand Up @@ -97,10 +113,3 @@ $cover-aspect: calc(3 / 2);
transform: none;
}
}

@media (max-width: 768px) {
.card {
width: 60%;
min-width: 0;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import classNames from 'classnames';
import Image from 'next/image';
import React, {
Dispatch,
SetStateAction,
Expand All @@ -17,6 +16,22 @@ import { HotspotMode, useHotspotTrigger } from './useHotspotTrigger';

import styles from './InteractiveCover.module.scss';

// Tracks a media query on the client; false during SSR and first paint so the
// markup is deterministic, then corrected in the effect after hydration.
function useMatchMedia(query: string) {
const [matches, setMatches] = useState(false);

useEffect(() => {
const mq = window.matchMedia(query);
const update = () => setMatches(mq.matches);
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, [query]);

return matches;
}

interface HotspotProps {
hotspot: CoverHotspot;
mode: HotspotMode;
Expand All @@ -32,7 +47,10 @@ function Hotspot({ hotspot, mode, activeId, setActiveId }: HotspotProps) {
setActiveId,
});

const { hit, highlight, card, library } = hotspot;
// The layer only mounts at 768px+, where the wide artwork is shown, so the
// wide geometry is the only one the hotspots ever render against.
const { library } = hotspot;
const { hit, highlight, card } = hotspot.wide;

return (
<>
Expand Down Expand Up @@ -66,7 +84,11 @@ function Hotspot({ hotspot, mode, activeId, setActiveId }: HotspotProps) {

<div
className={classNames(styles.card, { [styles.cardActive]: isActive })}
style={{ left: `${card.left}%`, top: `${card.top}%` }}
style={{
left: card.left !== undefined ? `${card.left}%` : undefined,
right: card.right !== undefined ? `${card.right}%` : undefined,
top: `${card.top}%`,
}}
>
<LibraryInfoCard {...library} isActive={isActive} />
</div>
Expand All @@ -76,13 +98,19 @@ function Hotspot({ hotspot, mode, activeId, setActiveId }: HotspotProps) {

export function InteractiveCover({
src,
wideSrc,
alt,
mode = 'hover',
className,
}: InteractiveCoverProps) {
const [activeId, setActiveId] = useState<string | null>(null);
const frameRef = useRef<HTMLDivElement>(null);

// 768px+ swaps to the wide artwork (see <source> below) and runs the hotspot
// layer with its wide geometry. Below that is mobile/touch territory: the
// taller art shows and hover hotspots are dropped.
const isWide = useMatchMedia('(min-width: 768px)');

// In click mode the card stays open until dismissed, so an outside click or
// Escape needs to close it. Hover mode dismisses itself via onMouseLeave.
useEffect(() => {
Expand Down Expand Up @@ -113,29 +141,33 @@ export function InteractiveCover({

return (
<div ref={frameRef} className={classNames(styles.frame, className)}>
<Image
className={styles.image}
src={src}
alt={alt}
fill
priority
sizes="100vw"
/>

<div
className={styles.layer}
onMouseLeave={mode === 'hover' ? clearActive : undefined}
>
{coverHotspots.map(hotspot => (
<Hotspot
key={hotspot.id}
hotspot={hotspot}
mode={mode}
activeId={activeId}
setActiveId={setActiveId}
/>
))}
</div>
<picture>
{wideSrc && <source media="(min-width: 768px)" srcSet={wideSrc} />}
<img
className={styles.image}
src={src}
alt={alt}
draggable={false}
fetchPriority="high"
/>
</picture>

{isWide && (
<div
className={styles.layer}
onMouseLeave={mode === 'hover' ? clearActive : undefined}
>
{coverHotspots.map(hotspot => (
<Hotspot
key={hotspot.id}
hotspot={hotspot}
mode={mode}
activeId={activeId}
setActiveId={setActiveId}
/>
))}
</div>
)}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { HotspotMode } from './useHotspotTrigger';
export interface InteractiveCoverProps {
/** Cover artwork rendered behind the hotspots. */
src: string;
/** Wider artwork served to 1920px+ viewports via <picture> art direction. */
wideSrc?: string;
alt: string;
/**
* How a hotspot reveals its card. Defaults to 'hover'; flip to 'click' for
Expand Down
Loading
Loading