@vvedantb/ui 0.26.1 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/app/src/convex/errorMessage.ts +62 -0
- package/app/src/convex/useHeldResult.ts +26 -0
- package/app/src/index.ts +10 -2
- package/app/src/settings/SettingsEmptyState.tsx +14 -1
- package/app/src/settings/SettingsSection.tsx +29 -4
- package/package.json +2 -1
- package/src/hooks/index.ts +5 -0
- package/src/hooks/use-document-visible.ts +15 -0
- package/src/hooks/use-idle-callback.ts +51 -0
- package/src/hooks/use-media-query.ts +17 -0
- package/src/hooks/use-persisted-scroll-parent.ts +70 -0
- package/src/hooks/use-single-flight.ts +59 -0
- package/src/index.ts +14 -0
- package/src/motion/list-enter.tsx +65 -0
- package/src/ui/count-pop.tsx +67 -0
- package/src/ui/error-boundary.tsx +48 -0
- package/src/ui/image-lightbox.tsx +197 -0
- package/src/ui/marquee-on-hover.tsx +80 -0
- package/src/ui/media-preview.tsx +173 -0
- package/src/ui/relative-date-time.tsx +42 -0
- package/src/ui/time-picker.tsx +104 -70
- package/src/ui/ui-config.tsx +4 -0
- package/tokens/src/css/marquee.css +43 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Component, type ErrorInfo, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export interface ErrorBoundaryProps {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
/**
|
|
6
|
+
* Rendered when a child throws (a failed query, a render bug). Pass a
|
|
7
|
+
* function to get a `retry` that clears the error and re-renders the
|
|
8
|
+
* children: a boundary with no way out stays broken until its parent
|
|
9
|
+
* remounts, which for a persistent pane means a full page reload.
|
|
10
|
+
*/
|
|
11
|
+
fallback?: ReactNode | ((retry: () => void) => ReactNode);
|
|
12
|
+
/** Called with the error. Default: `console.error` with the component stack. */
|
|
13
|
+
onError?: (error: Error, info: ErrorInfo) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ErrorBoundaryState {
|
|
17
|
+
hasError: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Contains a failure to its own subtree, so one broken pane or subscription
|
|
22
|
+
* cannot swap the whole layout for an error page.
|
|
23
|
+
*/
|
|
24
|
+
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
25
|
+
override state: ErrorBoundaryState = { hasError: false };
|
|
26
|
+
|
|
27
|
+
static getDerivedStateFromError(): ErrorBoundaryState {
|
|
28
|
+
return { hasError: true };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
override componentDidCatch(error: Error, info: ErrorInfo) {
|
|
32
|
+
if (this.props.onError) this.props.onError(error, info);
|
|
33
|
+
else console.error("ErrorBoundary caught:", error, info.componentStack);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private readonly retry = () => {
|
|
37
|
+
this.setState({ hasError: false });
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
override render() {
|
|
41
|
+
if (this.state.hasError) {
|
|
42
|
+
const { fallback } = this.props;
|
|
43
|
+
if (typeof fallback === "function") return fallback(this.retry);
|
|
44
|
+
return fallback ?? null;
|
|
45
|
+
}
|
|
46
|
+
return this.props.children;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
IconChevronLeft,
|
|
4
|
+
IconChevronRight,
|
|
5
|
+
IconExternalLink,
|
|
6
|
+
} from "@tabler/icons-react";
|
|
7
|
+
import { animate, m, useMotionValue } from "motion/react";
|
|
8
|
+
import { projectVelocity } from "../utils/gesture";
|
|
9
|
+
import { Dialog, DialogContent, DialogTitle } from "./dialog";
|
|
10
|
+
|
|
11
|
+
export type GalleryImage = { url: string; alt?: string };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Controls (arrows, "open in new tab") opt out of the swipe surface: taking
|
|
15
|
+
* pointer capture on the container retargets the resulting `click` to the
|
|
16
|
+
* container, so a captured drag would swallow every button press.
|
|
17
|
+
*/
|
|
18
|
+
const CONTROL_SELECTOR = "[data-lightbox-control]";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Fullscreen image viewer: cycles with the arrow keys, the arrow buttons or a
|
|
22
|
+
* 1:1 touch / pointer swipe (with velocity projection), wrapping at both ends.
|
|
23
|
+
* Controlled: `index` is `null` while closed. Uses `m`, so the app must render
|
|
24
|
+
* it inside `LazyMotion`.
|
|
25
|
+
*/
|
|
26
|
+
export function ImageLightbox({
|
|
27
|
+
images,
|
|
28
|
+
index,
|
|
29
|
+
onIndexChange,
|
|
30
|
+
onClose,
|
|
31
|
+
}: {
|
|
32
|
+
images: GalleryImage[];
|
|
33
|
+
/** `null` while closed. */
|
|
34
|
+
index: number | null;
|
|
35
|
+
onIndexChange: (next: number) => void;
|
|
36
|
+
onClose: () => void;
|
|
37
|
+
}) {
|
|
38
|
+
const offsetX = useMotionValue(0);
|
|
39
|
+
const draggingRef = useRef(false);
|
|
40
|
+
const startXRef = useRef(0);
|
|
41
|
+
const samplesRef = useRef<Array<{ x: number; t: number }>>([]);
|
|
42
|
+
|
|
43
|
+
const count = images.length;
|
|
44
|
+
const current = index === null ? null : images[index];
|
|
45
|
+
|
|
46
|
+
// Wrap-around so the user can circle through the set in either direction.
|
|
47
|
+
const step = (delta: number) => {
|
|
48
|
+
if (index === null || count === 0) return;
|
|
49
|
+
onIndexChange((index + delta + count) % count);
|
|
50
|
+
offsetX.set(0);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const sampleVelocity = (): number => {
|
|
54
|
+
const samples = samplesRef.current;
|
|
55
|
+
if (samples.length < 2) return 0;
|
|
56
|
+
const first = samples[0];
|
|
57
|
+
const last = samples[samples.length - 1];
|
|
58
|
+
if (first === undefined || last === undefined) return 0;
|
|
59
|
+
const dt = last.t - first.t;
|
|
60
|
+
if (dt <= 0) return 0;
|
|
61
|
+
return ((last.x - first.x) / dt) * 1000;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
65
|
+
if (count <= 1 || event.button !== 0) return;
|
|
66
|
+
if (
|
|
67
|
+
event.target instanceof Element &&
|
|
68
|
+
event.target.closest(CONTROL_SELECTOR) !== null
|
|
69
|
+
) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
73
|
+
draggingRef.current = true;
|
|
74
|
+
startXRef.current = event.clientX;
|
|
75
|
+
samplesRef.current = [{ x: event.clientX, t: performance.now() }];
|
|
76
|
+
offsetX.stop();
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
80
|
+
if (!draggingRef.current) return;
|
|
81
|
+
const dx = event.clientX - startXRef.current;
|
|
82
|
+
offsetX.set(dx);
|
|
83
|
+
samplesRef.current = [
|
|
84
|
+
...samplesRef.current.slice(-4),
|
|
85
|
+
{ x: event.clientX, t: performance.now() },
|
|
86
|
+
];
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const onPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
90
|
+
if (!draggingRef.current) return;
|
|
91
|
+
draggingRef.current = false;
|
|
92
|
+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
93
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const dx = offsetX.get();
|
|
97
|
+
const velocity = sampleVelocity();
|
|
98
|
+
const projected = dx + projectVelocity(velocity);
|
|
99
|
+
const goPrev = projected > 64 || velocity > 500;
|
|
100
|
+
const goNext = projected < -64 || velocity < -500;
|
|
101
|
+
|
|
102
|
+
if (goPrev || goNext) {
|
|
103
|
+
const target =
|
|
104
|
+
(goNext ? -1 : 1) * Math.max(280, window.innerWidth * 0.45);
|
|
105
|
+
void animate(offsetX, target, {
|
|
106
|
+
type: "spring",
|
|
107
|
+
bounce: 0,
|
|
108
|
+
duration: 0.35,
|
|
109
|
+
velocity,
|
|
110
|
+
}).then(() => step(goNext ? 1 : -1));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
void animate(offsetX, 0, {
|
|
115
|
+
type: "spring",
|
|
116
|
+
bounce: 0,
|
|
117
|
+
duration: 0.35,
|
|
118
|
+
velocity,
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<Dialog
|
|
124
|
+
open={index !== null}
|
|
125
|
+
onOpenChange={(open) => {
|
|
126
|
+
if (!open) {
|
|
127
|
+
offsetX.set(0);
|
|
128
|
+
onClose();
|
|
129
|
+
}
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
<DialogContent
|
|
133
|
+
className="h-[92dvh] max-w-[96vw] overflow-hidden border-0 bg-black/90 p-0 shadow-none sm:max-w-[96vw]"
|
|
134
|
+
onKeyDown={(event) => {
|
|
135
|
+
if (event.key === "ArrowLeft") step(-1);
|
|
136
|
+
if (event.key === "ArrowRight") step(1);
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
<DialogTitle className="sr-only">
|
|
140
|
+
{current?.alt ?? "Image"}
|
|
141
|
+
</DialogTitle>
|
|
142
|
+
{current ? (
|
|
143
|
+
<div
|
|
144
|
+
className="relative flex h-full w-full touch-pan-y items-center justify-center"
|
|
145
|
+
onPointerDown={onPointerDown}
|
|
146
|
+
onPointerMove={onPointerMove}
|
|
147
|
+
onPointerUp={onPointerUp}
|
|
148
|
+
onPointerCancel={onPointerUp}
|
|
149
|
+
>
|
|
150
|
+
<m.img
|
|
151
|
+
src={current.url}
|
|
152
|
+
alt={current.alt ?? "Image"}
|
|
153
|
+
draggable={false}
|
|
154
|
+
style={{ x: offsetX }}
|
|
155
|
+
className="max-h-full max-w-full select-none object-contain"
|
|
156
|
+
/>
|
|
157
|
+
{count > 1 ? (
|
|
158
|
+
<>
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
aria-label="Previous image"
|
|
162
|
+
data-lightbox-control=""
|
|
163
|
+
onClick={() => step(-1)}
|
|
164
|
+
className="max-sm:hit-target absolute left-3 top-1/2 -translate-y-1/2 rounded-full border border-white/20 bg-black/50 p-2 text-white motion-press active:scale-[0.94] hover:bg-black/70"
|
|
165
|
+
>
|
|
166
|
+
<IconChevronLeft size={22} />
|
|
167
|
+
</button>
|
|
168
|
+
<button
|
|
169
|
+
type="button"
|
|
170
|
+
aria-label="Next image"
|
|
171
|
+
data-lightbox-control=""
|
|
172
|
+
onClick={() => step(1)}
|
|
173
|
+
className="max-sm:hit-target absolute right-3 top-1/2 -translate-y-1/2 rounded-full border border-white/20 bg-black/50 p-2 text-white motion-press active:scale-[0.94] hover:bg-black/70"
|
|
174
|
+
>
|
|
175
|
+
<IconChevronRight size={22} />
|
|
176
|
+
</button>
|
|
177
|
+
<span className="absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full bg-black/60 px-3 py-1 text-xs tabular-nums text-white">
|
|
178
|
+
{index === null ? 1 : index + 1} / {count}
|
|
179
|
+
</span>
|
|
180
|
+
</>
|
|
181
|
+
) : null}
|
|
182
|
+
<a
|
|
183
|
+
href={current.url}
|
|
184
|
+
target="_blank"
|
|
185
|
+
rel="noopener noreferrer"
|
|
186
|
+
data-lightbox-control=""
|
|
187
|
+
className="max-sm:hit-target absolute right-12 top-3 inline-flex items-center gap-1 rounded-md bg-black/60 px-2 py-1 text-xs text-white/80 transition-colors hover:text-white"
|
|
188
|
+
>
|
|
189
|
+
<IconExternalLink size={14} />
|
|
190
|
+
Open in new tab
|
|
191
|
+
</a>
|
|
192
|
+
</div>
|
|
193
|
+
) : null}
|
|
194
|
+
</DialogContent>
|
|
195
|
+
</Dialog>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useEffect, useRef, type ReactNode } from "react";
|
|
2
|
+
import { useCn } from "./ui-config";
|
|
3
|
+
|
|
4
|
+
/** Pixels per second the revealed text travels: constant speed, so long labels do not whip by. */
|
|
5
|
+
const MARQUEE_SPEED_PX_PER_S = 60;
|
|
6
|
+
/** Clamp the scroll duration so very short and very long overflows both stay readable. */
|
|
7
|
+
const MIN_DURATION_S = 0.5;
|
|
8
|
+
const MAX_DURATION_S = 5;
|
|
9
|
+
|
|
10
|
+
interface MarqueeOnHoverProps {
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Single-line label that scrolls its hidden tail into view on hover ("reveal
|
|
17
|
+
* and hold") and shows a plain ellipsis the rest of the time. It moves only
|
|
18
|
+
* when the text overflows. Needs `@vvedantb/ui/tokens/css/marquee.css`.
|
|
19
|
+
*
|
|
20
|
+
* Two spans: the outer `mq-host` clips and the inner `mq-inner` translates.
|
|
21
|
+
* One element cannot both ellipsis-clip itself and translate its own overflow
|
|
22
|
+
* into view. Hover is pure CSS; this component only measures the overflow on
|
|
23
|
+
* layout changes and writes it to custom properties, so hovering never
|
|
24
|
+
* re-renders. Text styles go on `className` (the host) and are inherited; drop
|
|
25
|
+
* any `truncate` / `line-clamp` class from the caller.
|
|
26
|
+
*/
|
|
27
|
+
export function MarqueeOnHover({ children, className }: MarqueeOnHoverProps) {
|
|
28
|
+
const cn = useCn();
|
|
29
|
+
const hostRef = useRef<HTMLSpanElement>(null);
|
|
30
|
+
const innerRef = useRef<HTMLSpanElement>(null);
|
|
31
|
+
|
|
32
|
+
// Re-measure after a content change, a host resize or the web font swap,
|
|
33
|
+
// since each changes how much (if at all) the text overflows.
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const host = hostRef.current;
|
|
36
|
+
const inner = innerRef.current;
|
|
37
|
+
if (!host || !inner) return;
|
|
38
|
+
|
|
39
|
+
let cancelled = false;
|
|
40
|
+
let raf = 0;
|
|
41
|
+
const recompute = () => {
|
|
42
|
+
if (cancelled) return;
|
|
43
|
+
cancelAnimationFrame(raf);
|
|
44
|
+
raf = requestAnimationFrame(() => {
|
|
45
|
+
const shift = Math.max(0, inner.scrollWidth - host.clientWidth);
|
|
46
|
+
const overflows = shift > 1; // 1px guard against sub-pixel rounding jitter
|
|
47
|
+
host.dataset.mqOverflow = overflows ? "true" : "false";
|
|
48
|
+
// Native tooltip is the touch / long-hover fallback, only when truncated.
|
|
49
|
+
host.title = overflows ? (inner.textContent ?? "") : "";
|
|
50
|
+
if (overflows) {
|
|
51
|
+
const duration = Math.min(
|
|
52
|
+
MAX_DURATION_S,
|
|
53
|
+
Math.max(MIN_DURATION_S, shift / MARQUEE_SPEED_PX_PER_S),
|
|
54
|
+
);
|
|
55
|
+
host.style.setProperty("--mq-shift", `${shift}px`);
|
|
56
|
+
host.style.setProperty("--mq-dur", `${duration}s`);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
recompute();
|
|
62
|
+
const observer = new ResizeObserver(recompute);
|
|
63
|
+
observer.observe(host);
|
|
64
|
+
void document.fonts.ready.then(() => recompute());
|
|
65
|
+
|
|
66
|
+
return () => {
|
|
67
|
+
cancelled = true;
|
|
68
|
+
cancelAnimationFrame(raf);
|
|
69
|
+
observer.disconnect();
|
|
70
|
+
};
|
|
71
|
+
}, [children]);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<span ref={hostRef} className={cn("mq-host", className)}>
|
|
75
|
+
<span ref={innerRef} className="mq-inner">
|
|
76
|
+
{children}
|
|
77
|
+
</span>
|
|
78
|
+
</span>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
VideoPlayer,
|
|
4
|
+
VideoPlayerContent,
|
|
5
|
+
VideoPlayerControlBar,
|
|
6
|
+
VideoPlayerFullscreenButton,
|
|
7
|
+
VideoPlayerMuteButton,
|
|
8
|
+
VideoPlayerPlayButton,
|
|
9
|
+
VideoPlayerPlaybackRateButton,
|
|
10
|
+
VideoPlayerSeekBackwardButton,
|
|
11
|
+
VideoPlayerSeekForwardButton,
|
|
12
|
+
VideoPlayerTimeDisplay,
|
|
13
|
+
VideoPlayerTimeRange,
|
|
14
|
+
VideoPlayerVolumeRange,
|
|
15
|
+
} from "../kibo/video-player";
|
|
16
|
+
import { ImageLightbox, type GalleryImage } from "./image-lightbox";
|
|
17
|
+
import { useCn } from "./ui-config";
|
|
18
|
+
|
|
19
|
+
/** Hairline ring that separates media from any surface, in light and dark. */
|
|
20
|
+
const MEDIA_OUTLINE = "ring-1 ring-black/10 dark:ring-white/10";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Twitter-style image gallery: up to a 2x2 grid of cropped
|
|
24
|
+
* tiles (a "+N" overlay on the fourth when more exist) instead of a long
|
|
25
|
+
* stack, opening `ImageLightbox`. Videos are rendered separately
|
|
26
|
+
* (VideoPreview) — this is images only.
|
|
27
|
+
*/
|
|
28
|
+
export function ImageGalleryPreview({ images }: { images: GalleryImage[] }) {
|
|
29
|
+
const cn = useCn();
|
|
30
|
+
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
|
31
|
+
|
|
32
|
+
const count = images.length;
|
|
33
|
+
if (count === 0) return null;
|
|
34
|
+
|
|
35
|
+
const tiles = images.slice(0, 4);
|
|
36
|
+
const overflow = count - tiles.length;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<>
|
|
40
|
+
{count === 1 ? (
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
onClick={() => setLightboxIndex(0)}
|
|
44
|
+
className="block max-w-lg motion-press active:scale-[0.99]"
|
|
45
|
+
>
|
|
46
|
+
<img
|
|
47
|
+
src={images[0]?.url}
|
|
48
|
+
alt={images[0]?.alt ?? "Image"}
|
|
49
|
+
loading="lazy"
|
|
50
|
+
className={`${MEDIA_OUTLINE} rounded-surface max-h-96 max-w-full cursor-pointer transition-opacity hover:opacity-90`}
|
|
51
|
+
/>
|
|
52
|
+
</button>
|
|
53
|
+
) : (
|
|
54
|
+
<div
|
|
55
|
+
className={cn(
|
|
56
|
+
MEDIA_OUTLINE,
|
|
57
|
+
"rounded-surface grid max-w-lg gap-0.5 overflow-hidden",
|
|
58
|
+
"grid-cols-2",
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
{tiles.map((image, index) => (
|
|
62
|
+
<button
|
|
63
|
+
key={index}
|
|
64
|
+
type="button"
|
|
65
|
+
onClick={() => setLightboxIndex(index)}
|
|
66
|
+
className={cn(
|
|
67
|
+
"relative block cursor-pointer motion-press active:scale-[0.99]",
|
|
68
|
+
// Three images: first tile spans the left column, Twitter-style.
|
|
69
|
+
count === 3 && index === 0 ? "row-span-2 h-full" : undefined,
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
<img
|
|
73
|
+
src={image.url}
|
|
74
|
+
alt={image.alt ?? `Image ${index + 1}`}
|
|
75
|
+
loading="lazy"
|
|
76
|
+
className={cn(
|
|
77
|
+
"h-full w-full object-cover transition-opacity hover:opacity-90",
|
|
78
|
+
count === 3 && index === 0 ? "min-h-full" : "aspect-4/3",
|
|
79
|
+
)}
|
|
80
|
+
/>
|
|
81
|
+
{overflow > 0 && index === tiles.length - 1 ? (
|
|
82
|
+
<span className="absolute inset-0 flex items-center justify-center bg-black/60 text-xl font-semibold text-white">
|
|
83
|
+
+{overflow}
|
|
84
|
+
</span>
|
|
85
|
+
) : null}
|
|
86
|
+
</button>
|
|
87
|
+
))}
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
90
|
+
|
|
91
|
+
<ImageLightbox
|
|
92
|
+
images={images}
|
|
93
|
+
index={lightboxIndex}
|
|
94
|
+
onIndexChange={setLightboxIndex}
|
|
95
|
+
onClose={() => setLightboxIndex(null)}
|
|
96
|
+
/>
|
|
97
|
+
</>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Video with the full control bar; rate and volume drop out below `sm`. */
|
|
102
|
+
export function VideoPreview({
|
|
103
|
+
url,
|
|
104
|
+
className,
|
|
105
|
+
}: {
|
|
106
|
+
url: string;
|
|
107
|
+
className?: string;
|
|
108
|
+
}) {
|
|
109
|
+
const cn = useCn();
|
|
110
|
+
return (
|
|
111
|
+
<VideoPlayer className={cn("max-w-full", className)}>
|
|
112
|
+
<VideoPlayerContent
|
|
113
|
+
src={url}
|
|
114
|
+
playsInline
|
|
115
|
+
preload="metadata"
|
|
116
|
+
slot="media"
|
|
117
|
+
/>
|
|
118
|
+
{/* Nine controls do not fit a phone-width bar. Playback rate and the
|
|
119
|
+
volume slider drop out below `sm` — a phone has hardware volume keys,
|
|
120
|
+
and everything essential (play, scrub, time, mute, fullscreen) stays. */}
|
|
121
|
+
<VideoPlayerControlBar>
|
|
122
|
+
<VideoPlayerPlayButton />
|
|
123
|
+
<VideoPlayerSeekBackwardButton />
|
|
124
|
+
<VideoPlayerSeekForwardButton />
|
|
125
|
+
<VideoPlayerTimeRange />
|
|
126
|
+
<VideoPlayerTimeDisplay showDuration />
|
|
127
|
+
<VideoPlayerPlaybackRateButton
|
|
128
|
+
rates={[1, 3, 5, 8]}
|
|
129
|
+
className="max-sm:hidden"
|
|
130
|
+
/>
|
|
131
|
+
<VideoPlayerMuteButton />
|
|
132
|
+
<VideoPlayerVolumeRange className="max-sm:hidden" />
|
|
133
|
+
<VideoPlayerFullscreenButton />
|
|
134
|
+
</VideoPlayerControlBar>
|
|
135
|
+
</VideoPlayer>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** One image that opens `ImageLightbox` on click. */
|
|
140
|
+
export function ScreenshotPreview({
|
|
141
|
+
url,
|
|
142
|
+
alt = "Screenshot",
|
|
143
|
+
className,
|
|
144
|
+
}: {
|
|
145
|
+
url: string;
|
|
146
|
+
alt?: string;
|
|
147
|
+
className?: string;
|
|
148
|
+
}) {
|
|
149
|
+
const cn = useCn();
|
|
150
|
+
const [open, setOpen] = useState(false);
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<>
|
|
154
|
+
<button type="button" onClick={() => setOpen(true)} className="block">
|
|
155
|
+
<img
|
|
156
|
+
src={url}
|
|
157
|
+
alt={alt}
|
|
158
|
+
className={cn(
|
|
159
|
+
MEDIA_OUTLINE,
|
|
160
|
+
"rounded-surface max-w-full cursor-pointer transition-opacity hover:opacity-90",
|
|
161
|
+
className,
|
|
162
|
+
)}
|
|
163
|
+
/>
|
|
164
|
+
</button>
|
|
165
|
+
<ImageLightbox
|
|
166
|
+
images={[{ url, alt }]}
|
|
167
|
+
index={open ? 0 : null}
|
|
168
|
+
onIndexChange={() => undefined}
|
|
169
|
+
onClose={() => setOpen(false)}
|
|
170
|
+
/>
|
|
171
|
+
</>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { formatCompactRelativeTime } from "../../app/src/time/compactRelativeTime";
|
|
2
|
+
import dayjs from "../utils/dayjs";
|
|
3
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";
|
|
4
|
+
import { useCn } from "./ui-config";
|
|
5
|
+
|
|
6
|
+
export interface RelativeDateTimeProps {
|
|
7
|
+
/** Unix ms. When omitted, renders `emptyLabel` with no tooltip. */
|
|
8
|
+
at?: number;
|
|
9
|
+
/** Shown when `at` is missing. Default `"—"`. */
|
|
10
|
+
emptyLabel?: string;
|
|
11
|
+
/** dayjs format for the tooltip. Default `"DD/MM/YYYY HH:mm"`. */
|
|
12
|
+
exactFormat?: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Compact relative time ("15m", "2d", calendar-aware like `dayjs().diff`) with
|
|
18
|
+
* the exact date and time in a hover tooltip. Needs a `TooltipProvider` above.
|
|
19
|
+
* It does not tick: the parent re-renders it (e.g. with a shared clock).
|
|
20
|
+
*/
|
|
21
|
+
export function RelativeDateTime({
|
|
22
|
+
at,
|
|
23
|
+
emptyLabel = "—",
|
|
24
|
+
exactFormat = "DD/MM/YYYY HH:mm",
|
|
25
|
+
className,
|
|
26
|
+
}: RelativeDateTimeProps) {
|
|
27
|
+
const cn = useCn();
|
|
28
|
+
if (at === undefined) {
|
|
29
|
+
return <span className={cn("text-muted-foreground", className)}>{emptyLabel}</span>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<Tooltip>
|
|
34
|
+
<TooltipTrigger asChild>
|
|
35
|
+
<span className={cn("cursor-default text-muted-foreground tabular-nums", className)}>
|
|
36
|
+
{formatCompactRelativeTime(at, { calendar: true })}
|
|
37
|
+
</span>
|
|
38
|
+
</TooltipTrigger>
|
|
39
|
+
<TooltipContent>{dayjs(at).format(exactFormat)}</TooltipContent>
|
|
40
|
+
</Tooltip>
|
|
41
|
+
);
|
|
42
|
+
}
|