forked from unom/punktfunk
Pairing: - Refresh the paired-devices list after a native PIN pairing (the happy path never invalidated it, so a newly paired device stayed hidden until remount). - Moonlight PIN: a 204 means "PIN delivered to the waiting handshake", NOT paired, so it now reads "PIN sent" instead of a false "Paired successfully". - Hide the Moonlight pairing card on native-only hosts (HostInfo.gamestream) — it could never receive a PIN there. - Per-row pending on unpair/approve/deny; PIN input maxLength 16 (was 8). Displays / Library: - "Arrange displays" save refreshes the settings card (it rewrites the policy), without clobbering unsaved Custom edits (re-seed only when the draft still matches the server). - Live-display list wrapped in QueryState so errors don't read as "no displays". - "Forever" keep-alive option in the custom editor; edit-game form round-trips the logo artwork (was dropped on save); per-card delete pending. Stats: - Distinct colour for the native "queue" latency stage (it collided with "capture"). - "Not measured on this path" note on the GameStream health chart; configured-bitrate target line on throughput; host-authoritative elapsed timer; LiveCard surfaces non-404 errors. Shell / auth / i18n: - SSR-stable locale: first client render matches the base-locale SSR (no hydration mismatch), then adopts the persisted/browser locale post-hydration. - BFF proxy maps an upstream (mgmt-token) 401 to 502 so a logged-in user isn't bounced into a post-login redirect loop. - Logout checks the POST result before navigating; logs dedup by seq (StrictMode); login "next" keeps query/hash; Dashboard shows the active-session count. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
90 lines
2.7 KiB
TypeScript
90 lines
2.7 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
|
import { motion, stagger } from "motion/react";
|
|
import type { FC } from "react";
|
|
import {
|
|
getGetLibraryQueryKey,
|
|
useDeleteCustomGame,
|
|
useGetLibrary,
|
|
} from "@/api/gen/library/library";
|
|
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
|
import { QueryState } from "@/components/query-state";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import type { Loadable } from "@/lib/query";
|
|
import { m } from "@/paraglide/messages";
|
|
import { GameCard } from "./GameCard";
|
|
import { customId } from "./helpers";
|
|
|
|
/**
|
|
* Container: the library OVERVIEW — owns the listing query and per-card delete.
|
|
* Editing is escalated to the parent (it opens the separate add/edit form), so
|
|
* this subsection knows nothing about the form beyond firing `onEdit`.
|
|
*/
|
|
export const LibraryGridSection: FC<{ onEdit: (entry: GameEntry) => void }> = ({
|
|
onEdit,
|
|
}) => {
|
|
const qc = useQueryClient();
|
|
const library = useGetLibrary();
|
|
const remove = useDeleteCustomGame();
|
|
|
|
const onDelete = async (entry: GameEntry) => {
|
|
if (!confirm(m.library_delete_confirm())) return;
|
|
await remove
|
|
.mutateAsync({ id: customId(entry) })
|
|
.then(() => qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() }));
|
|
};
|
|
|
|
return (
|
|
<LibraryGrid
|
|
library={library}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
// The custom id whose delete is in flight (if any), so only that card's button disables.
|
|
deletingId={remove.isPending ? (remove.variables?.id ?? null) : null}
|
|
/>
|
|
);
|
|
};
|
|
|
|
/** The poster grid (with empty + loading/error states). */
|
|
export const LibraryGrid: FC<{
|
|
library: Loadable<GameEntry[]>;
|
|
onEdit: (entry: GameEntry) => void;
|
|
onDelete: (entry: GameEntry) => void;
|
|
/** Custom id of the card whose delete is in flight, or null — only that card disables. */
|
|
deletingId: string | null;
|
|
}> = ({ library, onEdit, onDelete, deletingId }) => {
|
|
const games = library.data ?? [];
|
|
return (
|
|
<QueryState
|
|
isLoading={library.isLoading}
|
|
error={library.error}
|
|
refetch={library.refetch}
|
|
>
|
|
{games.length === 0 ? (
|
|
<Card>
|
|
<CardContent className="p-8 text-center text-sm text-muted-foreground">
|
|
{m.library_empty()}
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<div className="@container">
|
|
<motion.div
|
|
transition={{ delayChildren: stagger(0.1) }}
|
|
variants={{ enter: {}, from: {} }}
|
|
className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5"
|
|
>
|
|
{games.map((game) => (
|
|
<GameCard
|
|
key={game.id}
|
|
game={game}
|
|
onEdit={() => onEdit(game)}
|
|
onDelete={() => onDelete(game)}
|
|
deleting={deletingId === customId(game)}
|
|
/>
|
|
))}
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
</QueryState>
|
|
);
|
|
};
|