Use Bootstrap Icons in clip menu
This commit is contained in:
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
@@ -2857,6 +2858,22 @@
|
||||
"@popperjs/core": "^2.11.8"
|
||||
}
|
||||
},
|
||||
"node_modules/bootstrap-icons": {
|
||||
"version": "1.13.1",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz",
|
||||
"integrity": "sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/twbs"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/bootstrap"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
import "bootstrap/dist/css/bootstrap.css";
|
||||
import "bootstrap-icons/font/bootstrap-icons.css";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
|
||||
@@ -19,6 +19,14 @@ const TRIM_STEP_MS = 100;
|
||||
const TRIM_ZOOM_WINDOW_MS = 3_000;
|
||||
const TRIM_ZOOM_SLIDER_MAX = 100;
|
||||
|
||||
function clipsQueryKey(teamId: string | null, playerId: string | null, includeHidden: boolean) {
|
||||
return ["clips", teamId, playerId, includeHidden ? "all" : "visible"] as const;
|
||||
}
|
||||
|
||||
function clipsQueryPrefix(teamId: string | null, playerId: string | null) {
|
||||
return ["clips", teamId, playerId] as const;
|
||||
}
|
||||
|
||||
type WalkupClipSourceMode = "upload" | "url" | "existing";
|
||||
|
||||
type WalkupClipModalState =
|
||||
@@ -28,13 +36,15 @@ type WalkupClipModalState =
|
||||
type BootstrapIconName =
|
||||
| "play"
|
||||
| "stop"
|
||||
| "three-dots-vertical"
|
||||
| "three-dots"
|
||||
| "pencil-square"
|
||||
| "plus-lg"
|
||||
| "x-lg"
|
||||
| "chevron-up"
|
||||
| "chevron-down"
|
||||
| "pin-fill";
|
||||
| "pin-fill"
|
||||
| "eye"
|
||||
| "eye-slash";
|
||||
type TrimFocusEdge = "start" | "end";
|
||||
type SourceCreationProgress = {
|
||||
label: string;
|
||||
@@ -47,6 +57,7 @@ export function LibraryPage() {
|
||||
const teamId = walkup.selectedTeamId;
|
||||
const playerId = walkup.currentPlayerId;
|
||||
const [walkupClipModal, setWalkupClipModal] = useState<WalkupClipModalState | null>(null);
|
||||
const [clipPinModalClipId, setClipPinModalClipId] = useState<number | null>(null);
|
||||
const [manageMediaOpen, setManageMediaOpen] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const previewClipIdRef = useRef<number | null>(null);
|
||||
@@ -60,8 +71,8 @@ export function LibraryPage() {
|
||||
enabled: Boolean(teamId && playerId),
|
||||
});
|
||||
const clipsQuery = useQuery({
|
||||
queryKey: ["clips", teamId, playerId],
|
||||
queryFn: () => api.listClips(teamId, playerId),
|
||||
queryKey: clipsQueryKey(teamId, playerId, true),
|
||||
queryFn: () => api.listClips(teamId, playerId, true),
|
||||
enabled: Boolean(teamId && playerId),
|
||||
});
|
||||
const pinsQuery = useQuery({
|
||||
@@ -82,6 +93,13 @@ export function LibraryPage() {
|
||||
const pinnedAssignmentsByClipAndGame = useMemo(() => {
|
||||
return new Map((pinsQuery.data ?? []).map((assignment) => [`${assignment.external_game_id}:${assignment.clip_id}`, assignment]));
|
||||
}, [pinsQuery.data]);
|
||||
const pinCountByClipId = useMemo(() => {
|
||||
const counts = new Map<number, number>();
|
||||
for (const assignment of pinsQuery.data ?? []) {
|
||||
counts.set(assignment.clip_id, (counts.get(assignment.clip_id) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}, [pinsQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -93,7 +111,7 @@ export function LibraryPage() {
|
||||
mutationFn: (clipId: number) => api.deleteClip(clipId, playerId),
|
||||
onSuccess: async () => {
|
||||
stopPreview();
|
||||
await queryClient.invalidateQueries({ queryKey: ["clips", teamId, playerId] });
|
||||
await queryClient.invalidateQueries({ queryKey: clipsQueryPrefix(teamId, playerId) });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -105,7 +123,29 @@ export function LibraryPage() {
|
||||
clip_ids: clipIds,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["clips", teamId, playerId] });
|
||||
await queryClient.invalidateQueries({ queryKey: clipsQueryPrefix(teamId, playerId) });
|
||||
},
|
||||
});
|
||||
|
||||
const toggleHiddenMutation = useMutation({
|
||||
mutationFn: async ({ clip, hidden }: { clip: AudioClip; hidden: boolean }) =>
|
||||
api.updateClip(
|
||||
clip.id,
|
||||
{
|
||||
label: clip.label,
|
||||
start_ms: clip.start_ms,
|
||||
end_ms: clip.end_ms,
|
||||
sort_order: clip.sort_order,
|
||||
hidden,
|
||||
},
|
||||
playerId,
|
||||
),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: clipsQueryPrefix(teamId, playerId) }),
|
||||
queryClient.invalidateQueries({ queryKey: ["assignments"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["prep"] }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -259,7 +299,7 @@ export function LibraryPage() {
|
||||
|
||||
return (
|
||||
<section className="page-grid">
|
||||
<div className="panel">
|
||||
<div className="panel walkup-library-panel">
|
||||
<div className="section-header clip-list-header">
|
||||
<div>
|
||||
<h2>My Clips</h2>
|
||||
@@ -304,11 +344,12 @@ export function LibraryPage() {
|
||||
}}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < orderedClips.length - 1}
|
||||
games={walkup.games}
|
||||
pinnedAssignmentsByClipAndGame={pinnedAssignmentsByClipAndGame}
|
||||
onTogglePin={(gameId) => {
|
||||
void togglePinMutation.mutateAsync({ clipId: clip.id, gameId });
|
||||
pinCount={pinCountByClipId.get(clip.id) ?? 0}
|
||||
onOpenPinModal={() => setClipPinModalClipId(clip.id)}
|
||||
onToggleHidden={() => {
|
||||
void toggleHiddenMutation.mutateAsync({ clip, hidden: !clip.hidden });
|
||||
}}
|
||||
isHidden={clip.hidden}
|
||||
/>
|
||||
))}
|
||||
{!clipsQuery.isLoading && !orderedClips.length ? (
|
||||
@@ -345,6 +386,18 @@ export function LibraryPage() {
|
||||
isDeletingClip={deleteClipMutation.isPending}
|
||||
/>
|
||||
) : null}
|
||||
{clipPinModalClipId !== null ? (
|
||||
<PinToGameModal
|
||||
clip={orderedClips.find((item) => item.id === clipPinModalClipId) ?? null}
|
||||
games={walkup.games}
|
||||
pinnedAssignmentsByClipAndGame={pinnedAssignmentsByClipAndGame}
|
||||
onClose={() => setClipPinModalClipId(null)}
|
||||
onTogglePin={(gameId, clipId) => {
|
||||
void togglePinMutation.mutateAsync({ clipId, gameId });
|
||||
}}
|
||||
isTogglingPin={togglePinMutation.isPending}
|
||||
/>
|
||||
) : null}
|
||||
{manageMediaOpen ? (
|
||||
<ManageUploadedMediaModal
|
||||
assets={assetsQuery.data ?? []}
|
||||
@@ -359,75 +412,21 @@ export function LibraryPage() {
|
||||
}
|
||||
|
||||
function BootstrapIcon({ name }: { name: BootstrapIconName }) {
|
||||
if (name === "play") {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||
<path d="M10.804 8 5 4.633v6.734zm.792-.696a.802.802 0 0 1 0 1.392l-6.363 3.692C4.713 12.69 4 12.345 4 11.692V4.308c0-.653.713-.998 1.233-.696z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const iconNameMap: Record<BootstrapIconName, string> = {
|
||||
play: "play-fill",
|
||||
stop: "stop-fill",
|
||||
"three-dots": "three-dots",
|
||||
"pencil-square": "pencil-square",
|
||||
"plus-lg": "plus-lg",
|
||||
"x-lg": "x-lg",
|
||||
"chevron-up": "chevron-up",
|
||||
"chevron-down": "chevron-down",
|
||||
"pin-fill": "pin-fill",
|
||||
eye: "eye",
|
||||
"eye-slash": "eye-slash",
|
||||
};
|
||||
|
||||
if (name === "stop") {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||
<path d="M3.5 5A1.5 1.5 0 0 1 5 3.5h6A1.5 1.5 0 0 1 12.5 5v6a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 11zM5 4.5a.5.5 0 0 0-.5.5v6a.5.5 0 0 0 .5.5h6a.5.5 0 0 0 .5-.5V5a.5.5 0 0 0-.5-.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === "three-dots-vertical") {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||
<path d="M9.5 1a1.5 1.5 0 1 1-3 0a1.5 1.5 0 0 1 3 0m0 7a1.5 1.5 0 1 1-3 0a1.5 1.5 0 0 1 3 0m0 7a1.5 1.5 0 1 1-3 0a1.5 1.5 0 0 1 3 0" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === "plus-lg") {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||
<path d="M8 2a.75.75 0 0 1 .75.75v4.5h4.5a.75.75 0 0 1 0 1.5h-4.5v4.5a.75.75 0 0 1-1.5 0v-4.5h-4.5a.75.75 0 0 1 0-1.5h4.5v-4.5A.75.75 0 0 1 8 2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === "chevron-up") {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||
<path d="M7.646 5.854a.5.5 0 0 1 .708 0l4.5 4.5a.5.5 0 0 1-.708.708L8 7.207 3.854 11.062a.5.5 0 1 1-.708-.708z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === "chevron-down") {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||
<path d="M1.646 5.854a.5.5 0 0 1 .708 0L8 11.5l5.646-5.646a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === "pin-fill") {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||
<path d="M4.146 1.5a.5.5 0 0 1 .708 0l3.75 3.75 1.646-1.646a1 1 0 0 1 1.414 0l.732.732a1 1 0 0 1 0 1.414L10.75 7.896l3.75 3.75-1.5 1.5-3.75-3.75-2.354 2.354a1 1 0 0 1-1.414 0l-.732-.732a1 1 0 0 1 0-1.414L6.354 7.25l-3.75-3.75a.5.5 0 0 1 0-.708z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === "x-lg") {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||
<path d="M2.146 2.146a.5.5 0 0 1 .708 0L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8 2.146 2.854a.5.5 0 0 1 0-.708" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||
<path d="M15.502 1.94a.5.5 0 0 1 0 .706l-.853.853-2-2 .853-.853a.5.5 0 0 1 .707 0l1.293 1.294zm-1.75 1.457-2-2L4.939 8.21a.5.5 0 0 0-.11.168l-1 3a.5.5 0 0 0 .64.64l3-1a.5.5 0 0 0 .168-.11l6.115-6.51zM4.257 11.518l.924-.308 6.563-6.992-1.5-1.5L3.25 9.7l-.308.924 1.315.894z" />
|
||||
</svg>
|
||||
);
|
||||
return <i className={`bi bi-${iconNameMap[name]} bootstrap-icon`} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
function WalkupClipModal({
|
||||
@@ -507,11 +506,11 @@ function WalkupClipModal({
|
||||
});
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", teamId, playerId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["clips", teamId, playerId] }),
|
||||
queryClient.invalidateQueries({ queryKey: clipsQueryPrefix(teamId, playerId) }),
|
||||
]);
|
||||
const refreshedClips = await queryClient.fetchQuery({
|
||||
queryKey: ["clips", teamId, playerId],
|
||||
queryFn: () => api.listClips(teamId, playerId),
|
||||
queryKey: clipsQueryKey(teamId, playerId, true),
|
||||
queryFn: () => api.listClips(teamId, playerId, true),
|
||||
});
|
||||
const createdClip = refreshedClips.find((clip) => clip.asset_id === assetId);
|
||||
if (!createdClip) {
|
||||
@@ -591,7 +590,7 @@ function WalkupClipModal({
|
||||
start_ms: 0,
|
||||
end_ms: DEFAULT_CLIP_LENGTH_MS,
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ["clips", teamId, playerId] });
|
||||
await queryClient.invalidateQueries({ queryKey: clipsQueryPrefix(teamId, playerId) });
|
||||
return clip;
|
||||
},
|
||||
onSuccess: (clip) => {
|
||||
@@ -839,7 +838,7 @@ function WalkupClipModal({
|
||||
onSaveComplete={async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", teamId, playerId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["clips", teamId, playerId] }),
|
||||
queryClient.invalidateQueries({ queryKey: clipsQueryPrefix(teamId, playerId) }),
|
||||
]);
|
||||
handleClose();
|
||||
}}
|
||||
@@ -903,7 +902,7 @@ function ManageUploadedMediaModal({
|
||||
stopPreview();
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", teamId, playerId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["clips", teamId, playerId] }),
|
||||
queryClient.invalidateQueries({ queryKey: clipsQueryPrefix(teamId, playerId) }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
@@ -931,12 +930,11 @@ function ManageUploadedMediaModal({
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary btn-sm icon-button"
|
||||
className="btn-close"
|
||||
onClick={handleClose}
|
||||
aria-label="Close modal"
|
||||
title="Close modal"
|
||||
>
|
||||
<BootstrapIcon name="x-lg" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="walkup-modal-body">
|
||||
@@ -973,9 +971,10 @@ function WalkupClipCard({
|
||||
onMoveDown,
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
games,
|
||||
pinnedAssignmentsByClipAndGame,
|
||||
onTogglePin,
|
||||
pinCount,
|
||||
onOpenPinModal,
|
||||
onToggleHidden,
|
||||
isHidden,
|
||||
}: {
|
||||
clip: AudioClip;
|
||||
isPreviewing: boolean;
|
||||
@@ -986,18 +985,35 @@ function WalkupClipCard({
|
||||
onMoveDown: () => void;
|
||||
canMoveUp: boolean;
|
||||
canMoveDown: boolean;
|
||||
games: TeamSnapEvent[];
|
||||
pinnedAssignmentsByClipAndGame: Map<string, { id: number }>;
|
||||
onTogglePin: (gameId: string) => void;
|
||||
pinCount: number;
|
||||
onOpenPinModal: () => void;
|
||||
onToggleHidden: () => void;
|
||||
isHidden: boolean;
|
||||
}) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [menuDirection, setMenuDirection] = useState<"down" | "up">("down");
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const menuButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const menuPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
const buttonRect = menuButtonRef.current?.getBoundingClientRect();
|
||||
const menuHeight = menuPanelRef.current?.getBoundingClientRect().height ?? 0;
|
||||
if (!buttonRect || !menuHeight) {
|
||||
setMenuDirection("down");
|
||||
return;
|
||||
}
|
||||
|
||||
const openDownSpace = window.innerHeight - buttonRect.bottom;
|
||||
const openUpSpace = buttonRect.top;
|
||||
setMenuDirection(openDownSpace < menuHeight + 16 && openUpSpace > menuHeight ? "up" : "down");
|
||||
});
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!menuRef.current?.contains(event.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
@@ -1013,6 +1029,7 @@ function WalkupClipCard({
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
@@ -1033,6 +1050,7 @@ function WalkupClipCard({
|
||||
</button>
|
||||
<div className="clip-summary-title-row">
|
||||
<strong>{clip.label}</strong>
|
||||
{isHidden ? <span className="pill">Hidden</span> : null}
|
||||
</div>
|
||||
<div className="clip-summary-order-controls">
|
||||
<button
|
||||
@@ -1059,24 +1077,139 @@ function WalkupClipCard({
|
||||
<div className="clip-summary-menu-wrap" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button-bare icon-button-menu"
|
||||
className="btn btn-sm btn-outline-secondary d-inline-flex align-items-center justify-content-center px-2"
|
||||
ref={menuButtonRef}
|
||||
onClick={() => setMenuOpen((current) => !current)}
|
||||
aria-label="Clip menu"
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="menu"
|
||||
title="Clip menu"
|
||||
>
|
||||
<BootstrapIcon name="three-dots-vertical" />
|
||||
<BootstrapIcon name="three-dots" />
|
||||
</button>
|
||||
{menuOpen ? (
|
||||
<div className="clip-summary-menu" role="menu">
|
||||
<button type="button" className="clip-summary-menu-item" role="menuitem" onClick={onEdit}>
|
||||
<div ref={menuPanelRef} className={`clip-summary-menu${menuDirection === "up" ? " is-up" : ""}`} role="menu">
|
||||
<div className="clip-summary-menu-label">Pinned to {pinCount} game{pinCount === 1 ? "" : "s"}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-secondary clip-summary-menu-action"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onOpenPinModal();
|
||||
}}
|
||||
>
|
||||
<span className="clip-summary-menu-icon">
|
||||
<BootstrapIcon name="pin-fill" />
|
||||
</span>
|
||||
<span>Pin to game</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-secondary clip-summary-menu-action"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onToggleHidden();
|
||||
}}
|
||||
>
|
||||
<span className="clip-summary-menu-icon">
|
||||
<BootstrapIcon name={isHidden ? "eye" : "eye-slash"} />
|
||||
</span>
|
||||
<span>{isHidden ? "Show in gameday" : "Hide from gameday"}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-secondary clip-summary-menu-action"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
<span className="clip-summary-menu-icon">
|
||||
<BootstrapIcon name="pencil-square" />
|
||||
</span>
|
||||
<span>Edit clip</span>
|
||||
</button>
|
||||
<div className="clip-summary-menu-label">Pin to game</div>
|
||||
<div className="clip-summary-menu-label">Source: {clip.asset_title}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PinToGameModal({
|
||||
clip,
|
||||
games,
|
||||
pinnedAssignmentsByClipAndGame,
|
||||
onClose,
|
||||
onTogglePin,
|
||||
isTogglingPin,
|
||||
}: {
|
||||
clip: AudioClip | null;
|
||||
games: TeamSnapEvent[];
|
||||
pinnedAssignmentsByClipAndGame: Map<string, { id: number }>;
|
||||
onClose: () => void;
|
||||
onTogglePin: (gameId: string, clipId: number) => void;
|
||||
isTogglingPin: boolean;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
if (!clip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="walkup-modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<section
|
||||
className="walkup-modal card shadow-lg border-0 w-100"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="pin-to-game-modal-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="walkup-modal-header">
|
||||
<div>
|
||||
<p className="eyebrow mb-1">Pin to game</p>
|
||||
<h2 id="pin-to-game-modal-title" className="h3 mb-0">
|
||||
{clip.label}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary btn-sm icon-button"
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
title="Close modal"
|
||||
>
|
||||
<BootstrapIcon name="x-lg" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="walkup-modal-body stack">
|
||||
<div className="panel-note">Choose which games should receive this clip. The clip can be pinned to multiple games or none at all.</div>
|
||||
<div className="clip-pin-game-list">
|
||||
{games.map((game) => {
|
||||
const gameId = String(game.id);
|
||||
const isPinned = pinnedAssignmentsByClipAndGame.has(`${gameId}:${clip.id}`);
|
||||
@@ -1084,26 +1217,27 @@ function WalkupClipCard({
|
||||
<button
|
||||
type="button"
|
||||
key={gameId}
|
||||
className={`clip-summary-menu-item${isPinned ? " is-active" : ""}`}
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={isPinned}
|
||||
onClick={() => {
|
||||
onTogglePin(gameId);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className={`clip-pin-game-row btn btn-outline-secondary${isPinned ? " is-active" : ""}`}
|
||||
onClick={() => onTogglePin(gameId, clip.id)}
|
||||
disabled={isTogglingPin}
|
||||
>
|
||||
<span className="clip-summary-menu-icon">
|
||||
<BootstrapIcon name="pin-fill" />
|
||||
</span>
|
||||
<span>{isPinned ? "Pinned: " : "Pin: "}{formatGameTitle(game)}</span>
|
||||
<div className="clip-pin-game-copy">
|
||||
<strong>{formatGameTitle(game)}</strong>
|
||||
<span className="muted">{isPinned ? "Pinned to this game" : "Not pinned"}</span>
|
||||
</div>
|
||||
<span className={`pill${isPinned ? " is-active" : ""}`}>{isPinned ? "Pinned" : "Pin"}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="clip-summary-menu-label">Source: {clip.asset_title}</div>
|
||||
{!games.length ? <div className="muted">No games are available for this team yet.</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="row walkup-modal-actions">
|
||||
<button type="button" className="btn btn-outline-secondary" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1598,7 +1732,7 @@ function UploadedMediaCard({
|
||||
setIsEditing(false);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", teamId, playerId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["clips", teamId, playerId] }),
|
||||
queryClient.invalidateQueries({ queryKey: clipsQueryPrefix(teamId, playerId) }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -43,6 +43,8 @@ select {
|
||||
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.operator-page {
|
||||
@@ -117,10 +119,15 @@ select {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.clip-list-add-button svg {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
fill: currentColor;
|
||||
.bootstrap-icon {
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
font-size: 1rem;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.clip-list-add-button .bootstrap-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.walkup-modal-backdrop {
|
||||
@@ -132,7 +139,7 @@ select {
|
||||
padding: 1rem;
|
||||
background: rgba(11, 18, 28, 0.76);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 3000;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.walkup-modal {
|
||||
@@ -140,7 +147,7 @@ select {
|
||||
max-height: 92vh;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
z-index: 3001;
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
.walkup-modal-header {
|
||||
@@ -247,6 +254,16 @@ select {
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.walkup-library-panel {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.walkup-library-panel + .panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.panel h2,
|
||||
.panel h3 {
|
||||
margin-top: 0;
|
||||
@@ -280,7 +297,10 @@ select {
|
||||
.clip-summary-title-row {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
@@ -331,11 +351,8 @@ select {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.icon-button-menu svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: block;
|
||||
fill: currentColor;
|
||||
.icon-button-menu .bootstrap-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.operator-panel-header {
|
||||
@@ -363,7 +380,7 @@ select {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.4rem);
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
z-index: 11000;
|
||||
min-width: 8.5rem;
|
||||
padding: 0.35rem;
|
||||
border: 1px solid var(--panel-border);
|
||||
@@ -403,12 +420,9 @@ select {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.icon-button svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
.icon-button .bootstrap-icon {
|
||||
font-size: 1em;
|
||||
vertical-align: -0.125em;
|
||||
fill: currentColor;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
@@ -438,7 +452,7 @@ select {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.45rem);
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
z-index: 11000;
|
||||
min-width: 12rem;
|
||||
padding: 0.45rem;
|
||||
border: 1px solid var(--panel-border);
|
||||
@@ -449,6 +463,11 @@ select {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.clip-summary-menu.is-up {
|
||||
top: auto;
|
||||
bottom: calc(100% + 0.45rem);
|
||||
}
|
||||
|
||||
.clip-summary-menu-item {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
@@ -462,6 +481,20 @@ select {
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.clip-summary-menu-action {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.clip-summary-menu-action .clip-summary-menu-icon {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.clip-summary-menu-item.is-active {
|
||||
background: rgba(217, 79, 4, 0.08);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.clip-summary-menu-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -471,12 +504,9 @@ select {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.clip-summary-menu-icon svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
.clip-summary-menu-icon .bootstrap-icon {
|
||||
font-size: 1em;
|
||||
vertical-align: -0.125em;
|
||||
fill: currentColor;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.clip-summary-menu-label {
|
||||
@@ -487,6 +517,65 @@ select {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.clip-details-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.clip-details-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.clip-details-actions .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.clip-details-game-list,
|
||||
.clip-pin-game-list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.clip-details-game-row,
|
||||
.clip-pin-game-row {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
padding: 0.75rem 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.clip-pin-game-row {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.clip-pin-game-row.is-active {
|
||||
border-color: rgba(217, 79, 4, 0.32);
|
||||
background: rgba(217, 79, 4, 0.08);
|
||||
}
|
||||
|
||||
.clip-pin-game-copy {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.clip-pin-game-row .pill.is-active {
|
||||
background: rgba(217, 79, 4, 0.12);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
min-width: 2.1rem;
|
||||
padding-inline: 0.5rem;
|
||||
|
||||
Reference in New Issue
Block a user