feat(player): clip mode - mark a range of what is playing and cut it into a download

Both players get a scissors in their control row (Plex: the control row; YouTube: the actions and
the docked bar), `c`, and `[` / `]` to mark at the playhead. The clip panel lives in the player, so
the watched video is the preview: an overview of the whole item and a zoomed strip around the
selection whose ends are sliders (arrows step a tenth, Shift a second), Start / End fields with
nudges and "to the playhead", the length, a looping Play selection, a name, Cut. Escape closes the
panel before the player and the focus goes back to the scissors. A cut is a toast with a Downloads
link plus the usual Ready notification.

- ui/RangeTrack + RulerTicks: the shared, domain-free time track (regions, keyboard-steppable
  handles with a 1.5rem hit area, a playhead, a background slot, over a window of the item). The
  Downloads editor's cuts moved onto it, so they are keyboard-steppable now.
- lib/clipRange holds the marking rules (1 s to 10 min, the server's), lib/timecode the editing
  timecode the editor's private tc/parseTc became.
- keymap: a symbol typed through AltGr answers its bare-symbol row - on the HU layout `[` is
  AltGr+F, which arrives as Ctrl+Alt; letter and Mod rows still never match Ctrl+Alt.
- The YouTube stage sits in a wrapper with the panel, so the maximised stage's blur escape matches
  at any depth.
- media-scope: fields on the always-dark player chrome read white in a light theme too.
This commit is contained in:
2026-10-10 03:58:27 +02:00
parent f221c1962a
commit 50157cc2fd
28 changed files with 2121 additions and 602 deletions
+7 -7
View File
@@ -35,13 +35,13 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
## What lives here
| area | components |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
| area | components |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel) |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
## The size contract (E10 S1c)
@@ -0,0 +1,157 @@
// @vitest-environment jsdom
//
// The shared time track: a handle is a slider the keyboard steps (and the step stops at its range),
// a drag reports its moves as not-final and its release as final, a press on the bare track seeks,
// and the window decides what is drawn. jsdom lays nothing out, so the track's box is stubbed.
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { RangeTrack, RulerTicks, type TrackHandle } from "./RangeTrack";
afterEach(cleanup);
beforeAll(() => {
// jsdom has no PointerEvent: a MouseEvent carries the button and clientX a drag reads.
class PointerEventStub extends MouseEvent {
pointerId: number;
constructor(type: string, init: PointerEventInit = {}) {
super(type, init);
this.pointerId = init.pointerId ?? 1;
}
}
globalThis.PointerEvent ??= PointerEventStub as unknown as typeof PointerEvent;
});
function box(el: Element, left = 0, width = 1000) {
el.getBoundingClientRect = () =>
({
left,
width,
top: 0,
height: 40,
right: left + width,
bottom: 40,
x: left,
y: 0,
}) as DOMRect;
}
function handle(over: Partial<TrackHandle> = {}): TrackHandle {
return {
id: "in",
time: 50,
min: 10,
max: 90,
label: "In",
valueText: "0:50.0",
step: 0.1,
bigStep: 1,
onChange: vi.fn(),
...over,
};
}
describe("RangeTrack", () => {
it("draws a handle as a slider with its time as the value", () => {
render(<RangeTrack duration={100} label="Clip" handles={[handle()]} />);
const s = screen.getByRole("slider", { name: "In" });
expect(s.getAttribute("aria-valuenow")).toBe("50");
expect(s.getAttribute("aria-valuetext")).toBe("0:50.0");
expect(s.style.left).toBe("50%");
});
it("steps a focused handle with the arrows, Shift for the big step, and takes the key", () => {
const h = handle();
render(<RangeTrack duration={100} label="Clip" handles={[h]} />);
const s = screen.getByRole("slider", { name: "In" });
const right = new KeyboardEvent("keydown", {
key: "ArrowRight",
bubbles: true,
cancelable: true,
});
s.dispatchEvent(right);
expect(h.onChange).toHaveBeenLastCalledWith(50.1, true);
expect(right.defaultPrevented).toBe(true);
fireEvent.keyDown(s, { key: "ArrowLeft", shiftKey: true });
expect(h.onChange).toHaveBeenLastCalledWith(49, true);
});
it("stops a step at the handle's range", () => {
const h = handle({ time: 89.95 });
render(<RangeTrack duration={100} label="Clip" handles={[h]} />);
const s = screen.getByRole("slider", { name: "In" });
fireEvent.keyDown(s, { key: "ArrowRight", shiftKey: true });
expect(h.onChange).toHaveBeenLastCalledWith(90, true);
fireEvent.keyDown(s, { key: "Home" });
expect(h.onChange).toHaveBeenLastCalledWith(10, true);
});
it("leaves a modified arrow to the page", () => {
const h = handle();
render(<RangeTrack duration={100} label="Clip" handles={[h]} />);
fireEvent.keyDown(screen.getByRole("slider", { name: "In" }), {
key: "ArrowRight",
ctrlKey: true,
});
expect(h.onChange).not.toHaveBeenCalled();
});
it("reports a drag's moves as not final and its release as final, clamped to the range", () => {
const h = handle();
const onSeek = vi.fn();
render(<RangeTrack duration={100} label="Clip" handles={[h]} onSeek={onSeek} />);
box(screen.getByRole("group", { name: "Clip" }));
const s = screen.getByRole("slider", { name: "In" });
fireEvent.pointerDown(s, { button: 0, clientX: 500, pointerId: 1 });
fireEvent.pointerMove(s, { clientX: 700, pointerId: 1 });
expect(h.onChange).toHaveBeenLastCalledWith(70, false);
fireEvent.pointerUp(s, { clientX: 990, pointerId: 1 });
expect(h.onChange).toHaveBeenLastCalledWith(90, true);
// The handle's press is not a seek of the track under it.
expect(onSeek).not.toHaveBeenCalled();
});
it("seeks on a press of the bare track, following the drag, final on release", () => {
const onSeek = vi.fn();
render(<RangeTrack duration={200} label="Clip" onSeek={onSeek} />);
const track = screen.getByRole("group", { name: "Clip" });
box(track);
fireEvent.pointerDown(track, { button: 0, clientX: 250, pointerId: 1 });
expect(onSeek).toHaveBeenLastCalledWith(50, false);
fireEvent.pointerMove(track, { clientX: 500, pointerId: 1 });
expect(onSeek).toHaveBeenLastCalledWith(100, false);
fireEvent.pointerUp(track, { clientX: 500, pointerId: 1 });
expect(onSeek).toHaveBeenLastCalledWith(100, true);
});
it("maps time through its window, and draws nothing that falls outside it", () => {
const onSeek = vi.fn();
render(
<RangeTrack
duration={3600}
view={{ from: 600, to: 700 }}
label="Zoom"
playhead={50}
onSeek={onSeek}
handles={[
handle({ id: "in", label: "In", time: 650, min: 0, max: 3600 }),
handle({ id: "out", label: "Out", time: 800, min: 0, max: 3600 }),
]}
/>,
);
const track = screen.getByRole("group", { name: "Zoom" });
box(track);
expect(screen.getByRole("slider", { name: "In" }).style.left).toBe("50%");
expect(screen.queryByRole("slider", { name: "Out" })).toBeNull();
expect(screen.queryByTestId("range-playhead")).toBeNull();
fireEvent.pointerDown(track, { button: 0, clientX: 100, pointerId: 1 });
expect(onSeek).toHaveBeenLastCalledWith(610, false);
});
});
describe("RulerTicks", () => {
it("picks a step that keeps the window to a handful of ticks", () => {
const { container } = render(<RulerTicks view={{ from: 0, to: 60 }} format={(t) => `${t}s`} />);
const labels = [...container.querySelectorAll("span")].map((s) => s.textContent);
expect(labels).toEqual(["0s", "10s", "20s", "30s", "40s", "50s", "60s"]);
});
});
+270
View File
@@ -0,0 +1,270 @@
import {
useState,
type CSSProperties,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from "react";
import clsx from "clsx";
// A horizontal time track: the shared body of every timeline that marks points in a piece of media
// (the download editor's cut list, the players' clip panel). It draws what it is given — regions
// (tinted spans), handles (draggable, keyboard-steppable marks), a playhead and a background slot
// (a filmstrip, a ruler) — over a WINDOW of the media's time, so the same track serves an overview of
// the whole item and a zoomed strip around a selection. What a mark means stays with the caller.
//
// A handle is a real control, not just a drag target: a `slider` with its time as the value, so Tab
// reaches it and the arrows step it (Shift for the big step), and a screen reader hears where it is.
// Its drawn bar is thin and its hit area 1.5rem wide, at least twice the bar — the long-timeline
// editors that drew the hit area as thin as the bar were the ones people missed.
//
// Every move reports `final`: false while a pointer drags, true on release or a key. A player whose
// seek is expensive (a transcode restarts) acts on the final one only; a cheap one follows the drag.
export interface RangeWindow {
from: number;
to: number;
}
export interface TrackHandle {
id: string;
time: number;
/** The range the handle may move in (seconds); a move past it stops at the edge. */
min: number;
max: number;
/** Accessible name ("In", "Cut 2"). */
label: string;
/** The time as read out (aria-valuetext), in the caller's own format. */
valueText: string;
/** Seconds per arrow key; `bigStep` with Shift or Page Up / Down. */
step: number;
bigStep: number;
onChange: (time: number, final: boolean) => void;
/** The drawn bar's look (its colour); the hit area around it is the track's. */
barClassName?: string;
/** Drawn on the handle, e.g. a remove button; it should stop its own pointer events. */
adornment?: ReactNode;
}
interface TrackRegion {
key: string | number;
start: number;
end: number;
className?: string;
style?: CSSProperties;
title?: string;
onPointerDown?: (e: PointerEvent<HTMLDivElement>) => void;
children?: ReactNode;
}
interface Props {
/** The media's length; with no `view` the track shows all of it. */
duration: number;
view?: RangeWindow;
playhead?: number | null;
/** A press or drag on the track itself (not on a handle): the time under the pointer. */
onSeek?: (time: number, final: boolean) => void;
/** The pointer over the track: its x within the track and the time there; null once it leaves. */
onHover?: (h: { x: number; time: number } | null) => void;
handles?: readonly TrackHandle[];
regions?: readonly TrackRegion[];
background?: ReactNode;
/** The track's box: its height, surface and edge. */
className?: string;
/** Accessible name of the whole track. */
label: string;
playheadClassName?: string;
}
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
/** The time under `clientX` on the track that holds `el`. */
function timeAtPointer(el: Element, clientX: number, win: RangeWindow): number {
const track = el.closest("[data-range-track]") ?? el;
const r = track.getBoundingClientRect();
const frac = r.width > 0 ? clamp((clientX - r.left) / r.width, 0, 1) : 0;
return win.from + frac * (win.to - win.from);
}
export function RangeTrack({
duration,
view,
playhead,
onSeek,
onHover,
handles = [],
regions = [],
background,
className,
label,
playheadClassName = "bg-white",
}: Props) {
const win: RangeWindow = view ?? { from: 0, to: duration };
const span = Math.max(win.to - win.from, 1e-6);
const frac = (t: number) => (t - win.from) / span;
const pct = (f: number) => `${(clamp(f, 0, 1) * 100).toFixed(3)}%`;
// The handle a pointer is dragging, and the press on the bare track (a scrub), so a move is only
// acted on between its own down and up.
const [dragging, setDragging] = useState<string | null>(null);
const [scrubbing, setScrubbing] = useState(false);
const capture = (e: PointerEvent<HTMLElement>) => {
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
// No capture (a synthetic event): the drag still follows while the pointer stays on the target.
}
};
return (
<div
data-range-track=""
role="group"
aria-label={label}
className={clsx("relative touch-none select-none overflow-hidden", className)}
onPointerDown={(e) => {
if (!onSeek || e.button !== 0) return;
capture(e);
setScrubbing(true);
onSeek(timeAtPointer(e.currentTarget, e.clientX, win), false);
}}
onPointerMove={(e) => {
const time = timeAtPointer(e.currentTarget, e.clientX, win);
onHover?.({ x: e.clientX - e.currentTarget.getBoundingClientRect().left, time });
if (scrubbing) onSeek?.(time, false);
}}
onPointerUp={(e) => {
if (!scrubbing) return;
setScrubbing(false);
onSeek?.(timeAtPointer(e.currentTarget, e.clientX, win), true);
}}
onPointerCancel={() => setScrubbing(false)}
onPointerLeave={() => onHover?.(null)}
>
{background}
{regions.map((r) => {
const a = clamp(frac(r.start), 0, 1);
const b = clamp(frac(r.end), 0, 1);
if (b <= a && r.end > r.start) return null; // wholly outside the window
return (
<div
key={r.key}
className={clsx("absolute inset-y-0", r.className)}
style={{ left: pct(a), width: pct(b - a), ...r.style }}
title={r.title}
onPointerDown={r.onPointerDown}
>
{r.children}
</div>
);
})}
{playhead != null && playhead >= win.from && playhead <= win.to && (
<div
data-testid="range-playhead"
className={clsx(
"pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2",
playheadClassName,
)}
style={{ left: pct(frac(playhead)) }}
/>
)}
{handles.map((h) => {
const f = frac(h.time);
if (f < 0 || f > 1) return null;
const move = (time: number, final: boolean) => h.onChange(clamp(time, h.min, h.max), final);
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const big = e.shiftKey ? h.bigStep : h.step;
const to: Record<string, number> = {
ArrowLeft: h.time - big,
ArrowDown: h.time - big,
ArrowRight: h.time + big,
ArrowUp: h.time + big,
PageDown: h.time - h.bigStep,
PageUp: h.time + h.bigStep,
Home: h.min,
End: h.max,
};
const next = to[e.key];
if (next === undefined || e.altKey || e.ctrlKey || e.metaKey) return;
// Taken here, so the player underneath does not also seek on the same arrow (`dispatch`
// skips a handled key).
e.preventDefault();
e.stopPropagation();
move(next, true);
};
return (
<div
key={h.id}
role="slider"
tabIndex={0}
aria-label={h.label}
aria-valuemin={Math.round(h.min * 10) / 10}
aria-valuemax={Math.round(h.max * 10) / 10}
aria-valuenow={Math.round(h.time * 10) / 10}
aria-valuetext={h.valueText}
data-handle={h.id}
// The ring sits inside the hit area: the track clips its overflow.
className="absolute inset-y-0 z-base flex w-6 -translate-x-1/2 cursor-ew-resize touch-none items-center justify-center rounded-sm focus-visible:-outline-offset-2"
style={{ left: pct(f) }}
onKeyDown={onKeyDown}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.stopPropagation();
capture(e);
setDragging(h.id);
}}
onPointerMove={(e) => {
if (dragging === h.id) move(timeAtPointer(e.currentTarget, e.clientX, win), false);
}}
onPointerUp={(e) => {
if (dragging !== h.id) return;
e.stopPropagation();
setDragging(null);
move(timeAtPointer(e.currentTarget, e.clientX, win), true);
}}
onPointerCancel={() => setDragging(null)}
>
<div className={clsx("h-full w-1 rounded-sm", h.barClassName ?? "bg-white/90")} />
{h.adornment}
</div>
);
})}
</div>
);
}
const NICE_STEPS = [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200];
/** Time ticks for a track's background: a nice step that gives the window at most `max` of them,
* each labelled by `format`. */
export function RulerTicks({
view,
format,
max = 8,
className,
}: {
view: RangeWindow;
format: (t: number) => string;
max?: number;
className?: string;
}) {
const span = Math.max(view.to - view.from, 1e-6);
const step = NICE_STEPS.find((s) => span / s <= max) ?? NICE_STEPS[NICE_STEPS.length - 1]!;
const ticks: number[] = [];
for (let t = Math.ceil(view.from / step) * step; t <= view.to; t += step) ticks.push(t);
return (
<div aria-hidden="true" className={clsx("pointer-events-none absolute inset-0", className)}>
{ticks.map((t) => (
<div
key={t}
className="absolute bottom-0 h-2 border-l border-current"
style={{ left: `${(((t - view.from) / span) * 100).toFixed(3)}%` }}
>
<span className="absolute bottom-2 left-1 text-3xs tabular-nums whitespace-nowrap">
{format(t)}
</span>
</div>
))}
</div>
);
}