Files
cinny/src/app/components/GifPicker.tsx
T

247 lines
7.0 KiB
TypeScript
Raw Normal View History

import React, { useCallback } from 'react';
import FocusTrap from 'focus-trap-react';
2026-07-10 12:27:18 -04:00
import { useAtom } from 'jotai';
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
import { IGif } from '@giphy/js-types';
import { Box, color, config } from 'folds';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
2026-07-10 12:27:18 -04:00
import { addRecentGif, RecentGif, recentGifsAtom } from '../state/recentGifs';
const PICKER_WIDTH = 312;
const PICKER_WIDTH_CSS = `min(${PICKER_WIDTH}px, calc(100vw - 16px))`;
type GifPickerInnerProps = {
onSelect: (url: string, width: number, height: number) => void;
requestClose: () => void;
lotusTerminal: boolean;
};
2026-07-10 12:27:18 -04:00
// Small monospace section header matching the picker's `// GIF_SEARCH` treatment
// (lotusTerminal) / a muted label otherwise.
const RECENT_LABEL_ID = 'gif-picker-recent-label';
function SectionLabel({
text,
lotusTerminal,
id,
}: {
text: string;
lotusTerminal: boolean;
id?: string;
}) {
2026-07-10 12:27:18 -04:00
if (lotusTerminal) {
return (
<div
id={id}
2026-07-10 12:27:18 -04:00
style={{
padding: '4px 2px',
fontFamily: "'JetBrains Mono', 'Cascadia Code', monospace",
fontSize: '10px',
fontWeight: 700,
letterSpacing: '0.1em',
color: 'var(--lt-accent-orange)',
userSelect: 'none',
}}
>
{`// ${text.toUpperCase()}`}
</div>
);
}
return (
<div
id={id}
2026-07-10 12:27:18 -04:00
style={{
padding: '2px 2px 4px',
fontSize: '11px',
fontWeight: 600,
color: color.Surface.OnContainer,
opacity: 0.6,
userSelect: 'none',
}}
>
{text}
</div>
);
}
function RecentGifs({
recents,
lotusTerminal,
onPick,
}: {
recents: RecentGif[];
lotusTerminal: boolean;
onPick: (gif: RecentGif) => void;
2026-07-10 12:27:18 -04:00
}) {
return (
<div style={{ marginBottom: 8 }}>
<SectionLabel text="Recent" lotusTerminal={lotusTerminal} id={RECENT_LABEL_ID} />
<div
role="group"
aria-labelledby={RECENT_LABEL_ID}
style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4 }}
>
{recents.map((g, i) => (
2026-07-10 12:27:18 -04:00
<button
key={g.url}
type="button"
aria-label={`Send recent GIF ${i + 1} of ${recents.length}`}
onClick={() => onPick(g)}
2026-07-10 12:27:18 -04:00
style={{
padding: 0,
border: 'none',
background: 'transparent',
cursor: 'pointer',
height: 72,
borderRadius: lotusTerminal ? '4px' : '8px',
overflow: 'hidden',
}}
>
{/* A still preview (falls back to the animated url for pre-existing
recents) so the Recent row doesn't autoplay many GIFs at once. */}
2026-07-10 12:27:18 -04:00
<img
src={g.previewUrl ?? g.url}
2026-07-10 12:27:18 -04:00
alt=""
loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
</button>
))}
</div>
</div>
);
}
function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInnerProps) {
2026-07-10 12:27:18 -04:00
const { fetchGifs, searchKey, term } = React.useContext(SearchContext);
const [recents, setRecents] = useAtom(recentGifsAtom);
const sendGif = useCallback(
(gif: RecentGif) => {
setRecents((prev) => addRecentGif(prev, gif));
onSelect(gif.url, gif.width, gif.height);
2026-07-10 12:27:18 -04:00
requestClose();
},
[onSelect, requestClose, setRecents],
);
const handleClick = useCallback(
(gif: IGif, e: React.SyntheticEvent) => {
e.preventDefault();
const r = gif.images.downsized ?? gif.images.original;
const previewUrl =
gif.images.fixed_width_small_still?.url ??
gif.images.downsized_still?.url ??
gif.images.original_still?.url;
sendGif({
url: r.url,
width: Number(r.width) || 200,
height: Number(r.height) || 200,
previewUrl,
});
},
2026-07-10 12:27:18 -04:00
[sendGif],
);
2026-07-10 12:27:18 -04:00
const showRecents = recents.length > 0 && !(term ?? '').trim();
return (
<Box direction="Column" style={{ width: PICKER_WIDTH_CSS }}>
{lotusTerminal && (
<div
style={{
padding: '5px 10px 4px',
borderBottom: '1px solid color-mix(in srgb, var(--lt-accent-orange) 20%, transparent)',
fontFamily: "'JetBrains Mono', 'Cascadia Code', monospace",
fontSize: '10px',
fontWeight: 700,
letterSpacing: '0.1em',
color: 'var(--lt-accent-orange)',
userSelect: 'none',
}}
>
{'// GIF_SEARCH'}
</div>
)}
<Box style={{ padding: '8px 8px 4px' }}>
<div style={{ width: '100%', borderRadius: lotusTerminal ? '4px' : '8px' }}>
<SearchBar />
</div>
</Box>
<div
style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '340px', padding: '0 8px 8px' }}
>
2026-07-10 12:27:18 -04:00
{showRecents && (
<RecentGifs recents={recents} lotusTerminal={lotusTerminal} onPick={sendGif} />
)}
<Grid
key={searchKey}
fetchGifs={fetchGifs}
width={PICKER_WIDTH - 16}
columns={2}
gutter={4}
onGifClick={handleClick}
hideAttribution={false}
noLink
/>
</div>
</Box>
);
}
type GifPickerProps = {
apiKey: string;
onSelect: (url: string, width: number, height: number) => void;
requestClose: () => void;
};
export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const containerStyle = lotusTerminal
? {
background: 'var(--lt-bg-secondary)',
border: '1px solid color-mix(in srgb, var(--lt-accent-orange) 35%, transparent)',
borderRadius: '4px',
overflow: 'hidden',
2026-06-10 22:55:32 -04:00
boxShadow:
'0 4px 24px color-mix(in srgb, var(--lt-accent-orange) 10%, transparent), 0 0 0 1px color-mix(in srgb, var(--lt-accent-orange) 8%, transparent)',
width: PICKER_WIDTH_CSS,
}
: {
background: color.Surface.Container,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
borderRadius: config.radii.R400,
overflow: 'hidden',
boxShadow: color.Other.Shadow,
width: PICKER_WIDTH_CSS,
};
return (
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: true,
onDeactivate: requestClose,
clickOutsideDeactivates: true,
allowOutsideClick: true,
}}
>
<Box
direction="Column"
data-gif-terminal={lotusTerminal ? '' : undefined}
style={containerStyle}
>
<SearchContextManager apiKey={apiKey} initialTerm="">
<GifPickerInner
onSelect={onSelect}
requestClose={requestClose}
lotusTerminal={!!lotusTerminal}
/>
</SearchContextManager>
</Box>
</FocusTrap>
);
}