Add offline clip caching
This commit is contained in:
@@ -10,6 +10,7 @@ import { ProfilePage } from "./pages/ProfilePage";
|
||||
import { AdminPage } from "./pages/AdminPage";
|
||||
import { SignInPage } from "./pages/SignInPage";
|
||||
import { formatTeamLabel } from "./lib/teamsnapHelpers";
|
||||
import { useOnlineStatus } from "./hooks/useOnlineStatus";
|
||||
|
||||
function getRouteDestinationLabel(pathname: string) {
|
||||
switch (pathname) {
|
||||
@@ -227,6 +228,7 @@ function ShellLayout() {
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const walkup = useWalkupContext();
|
||||
const location = useLocation();
|
||||
const isOnline = useOnlineStatus();
|
||||
const currentPageLabel = getNavbarPageLabel(location.pathname);
|
||||
const showNavbar = walkup.sessionQuery.data?.authenticated === true;
|
||||
const showTeamSelectionModal = walkup.isTeamSnap && walkup.teamsQuery.isFetched && !walkup.hasSelectedTeam;
|
||||
@@ -249,7 +251,7 @@ function ShellLayout() {
|
||||
}, [showTeamSelectionModal]);
|
||||
|
||||
return (
|
||||
<div className={shellClassName}>
|
||||
<div className={shellClassName}>
|
||||
{showNavbar ? (
|
||||
<nav className="navbar navbar-expand-lg navbar-dark bg-dark shadow-sm sticky-top px-3 py-2" aria-label="Primary">
|
||||
<div className="container-fluid">
|
||||
@@ -301,6 +303,11 @@ function ShellLayout() {
|
||||
</div>
|
||||
</nav>
|
||||
) : null}
|
||||
{!isOnline ? (
|
||||
<div className="alert alert-warning rounded-0 border-0 mb-0 py-2 text-center" role="status">
|
||||
Offline mode: showing cached clips and previously loaded game data until the connection returns.
|
||||
</div>
|
||||
) : null}
|
||||
<main className="container-fluid py-4">
|
||||
<Routes>
|
||||
<Route path="/signin" element={<SignInRoute />} />
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
SessionResponse,
|
||||
TeamSnapTokenResponse,
|
||||
} from "./types";
|
||||
import { cachedJsonRequest, clearOfflineCache } from "../lib/offlineCache";
|
||||
|
||||
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||
|
||||
@@ -22,13 +23,15 @@ type UploadAssetPayload = {
|
||||
};
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (init?.body != null && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
credentials: "include",
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -40,7 +43,18 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getSession: () => request<SessionResponse>("/auth/session"),
|
||||
getSession: () =>
|
||||
cachedJsonRequest<SessionResponse>(
|
||||
["session"],
|
||||
`${API_BASE}/auth/session`,
|
||||
undefined,
|
||||
{
|
||||
onUnauthorized: () => {
|
||||
clearOfflineCache();
|
||||
return { authenticated: false, is_admin: false };
|
||||
},
|
||||
},
|
||||
),
|
||||
startTeamSnap: (returnTo: string) =>
|
||||
request<{ authorize_url: string; state: string }>(`/auth/teamsnap/start?return_to=${encodeURIComponent(returnTo)}`),
|
||||
getTeamSnapToken: () => request<TeamSnapTokenResponse>("/auth/teamsnap/token", { method: "POST" }),
|
||||
@@ -50,8 +64,9 @@ export const api = {
|
||||
updateWalkupSessionSelection: (payload: { external_team_id: string; external_player_id: string }) =>
|
||||
request<SessionResponse>("/auth/session/walkup", { method: "POST", body: JSON.stringify(payload) }),
|
||||
listAssets: (teamId: string, playerId?: string) =>
|
||||
request<AudioAsset[]>(
|
||||
`/media/assets?external_team_id=${encodeURIComponent(teamId)}${
|
||||
cachedJsonRequest<AudioAsset[]>(
|
||||
["assets", teamId, playerId ?? ""],
|
||||
`${API_BASE}/media/assets?external_team_id=${encodeURIComponent(teamId)}${
|
||||
playerId ? `&owner_external_player_id=${encodeURIComponent(playerId)}` : ""
|
||||
}`,
|
||||
),
|
||||
@@ -131,8 +146,9 @@ export const api = {
|
||||
}
|
||||
},
|
||||
listClips: (teamId: string, playerId?: string, includeHidden = false) =>
|
||||
request<AudioClip[]>(
|
||||
`/media/clips?external_team_id=${encodeURIComponent(teamId)}${
|
||||
cachedJsonRequest<AudioClip[]>(
|
||||
["clips", teamId, playerId ?? "", includeHidden ? "all" : "visible"],
|
||||
`${API_BASE}/media/clips?external_team_id=${encodeURIComponent(teamId)}${
|
||||
playerId ? `&owner_external_player_id=${encodeURIComponent(playerId)}` : ""
|
||||
}${includeHidden ? "&include_hidden=true" : ""}`,
|
||||
),
|
||||
@@ -159,13 +175,14 @@ export const api = {
|
||||
}
|
||||
},
|
||||
listAssignments: (gameId: string, playerId?: string) =>
|
||||
request<GameAssignment[]>(
|
||||
`/games/${encodeURIComponent(gameId)}/assignments${
|
||||
cachedJsonRequest<GameAssignment[]>(
|
||||
["assignments", gameId, playerId ?? ""],
|
||||
`${API_BASE}/games/${encodeURIComponent(gameId)}/assignments${
|
||||
playerId ? `?external_player_id=${encodeURIComponent(playerId)}` : ""
|
||||
}`,
|
||||
),
|
||||
listPins: (playerId: string) =>
|
||||
request<GameAssignment[]>(`/games/pins?external_player_id=${encodeURIComponent(playerId)}`),
|
||||
cachedJsonRequest<GameAssignment[]>(["pins", playerId], `${API_BASE}/games/pins?external_player_id=${encodeURIComponent(playerId)}`),
|
||||
createAssignment: (
|
||||
gameId: string,
|
||||
payload: {
|
||||
@@ -194,5 +211,6 @@ export const api = {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
}),
|
||||
prepareGame: (gameId: string) => request<GamePrepResponse>(`/games/${encodeURIComponent(gameId)}/prep`),
|
||||
prepareGame: (gameId: string) =>
|
||||
cachedJsonRequest<GamePrepResponse>(["prep", gameId], `${API_BASE}/games/${encodeURIComponent(gameId)}/prep`),
|
||||
};
|
||||
|
||||
29
frontend/src/hooks/useOnlineStatus.ts
Normal file
29
frontend/src/hooks/useOnlineStatus.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useOnlineStatus(): boolean {
|
||||
const [isOnline, setIsOnline] = useState(() => {
|
||||
if (typeof navigator === "undefined") {
|
||||
return true;
|
||||
}
|
||||
return navigator.onLine;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function handleOnline() {
|
||||
setIsOnline(true);
|
||||
}
|
||||
|
||||
function handleOffline() {
|
||||
setIsOnline(false);
|
||||
}
|
||||
|
||||
window.addEventListener("online", handleOnline);
|
||||
window.addEventListener("offline", handleOffline);
|
||||
return () => {
|
||||
window.removeEventListener("online", handleOnline);
|
||||
window.removeEventListener("offline", handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isOnline;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export function useSession() {
|
||||
return useQuery({
|
||||
queryKey: ["session"],
|
||||
queryFn: api.getSession,
|
||||
networkMode: "always",
|
||||
retry: 0,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ function useBuildWalkupContext() {
|
||||
queryKey: ["teamsnap", "teams"],
|
||||
queryFn: () => teamsnapClient.loadTeams(),
|
||||
enabled: isTeamSnap,
|
||||
networkMode: "always",
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const teams = teamsQuery.data ?? [];
|
||||
@@ -54,11 +56,15 @@ function useBuildWalkupContext() {
|
||||
queryKey: ["teamsnap", "members", resolvedTeamId],
|
||||
queryFn: () => teamsnapClient.loadMembers(resolvedTeamId),
|
||||
enabled: isTeamSnap && Boolean(resolvedTeamId),
|
||||
networkMode: "always",
|
||||
retry: 0,
|
||||
});
|
||||
const eventsQuery = useQuery({
|
||||
queryKey: ["teamsnap", "events", resolvedTeamId],
|
||||
queryFn: () => teamsnapClient.loadEvents(resolvedTeamId),
|
||||
enabled: isTeamSnap && Boolean(resolvedTeamId),
|
||||
networkMode: "always",
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const members: TeamSnapMember[] = membersQuery.data ?? [];
|
||||
|
||||
149
frontend/src/lib/offlineCache.ts
Normal file
149
frontend/src/lib/offlineCache.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
type CacheEntry<T> = {
|
||||
cachedAt: string;
|
||||
data: T;
|
||||
etag?: string;
|
||||
};
|
||||
|
||||
type CacheStore = {
|
||||
version: 1;
|
||||
entries: Record<string, CacheEntry<unknown>>;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "walkup.offlineCache:v1";
|
||||
|
||||
function safeLocalStorage(): Storage | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { localStorage } = window;
|
||||
const probeKey = "__walkup_cache_probe__";
|
||||
localStorage.setItem(probeKey, "1");
|
||||
localStorage.removeItem(probeKey);
|
||||
return localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readStore(): CacheStore {
|
||||
const storage = safeLocalStorage();
|
||||
if (!storage) {
|
||||
return { version: 1, entries: {} };
|
||||
}
|
||||
|
||||
const raw = storage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return { version: 1, entries: {} };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<CacheStore>;
|
||||
if (parsed.version !== 1 || !parsed.entries || typeof parsed.entries !== "object") {
|
||||
return { version: 1, entries: {} };
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
entries: parsed.entries as Record<string, CacheEntry<unknown>>,
|
||||
};
|
||||
} catch {
|
||||
return { version: 1, entries: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function writeStore(store: CacheStore): void {
|
||||
const storage = safeLocalStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||
} catch {
|
||||
// Ignore quota and storage errors. The app can still operate online.
|
||||
}
|
||||
}
|
||||
|
||||
function cacheKeyFromParts(parts: readonly unknown[]): string {
|
||||
return JSON.stringify(parts);
|
||||
}
|
||||
|
||||
export function readCachedValue<T>(parts: readonly unknown[]): CacheEntry<T> | null {
|
||||
const store = readStore();
|
||||
const entry = store.entries[cacheKeyFromParts(parts)];
|
||||
return entry ? (entry as CacheEntry<T>) : null;
|
||||
}
|
||||
|
||||
export function writeCachedValue<T>(parts: readonly unknown[], data: T, etag?: string): void {
|
||||
const store = readStore();
|
||||
store.entries[cacheKeyFromParts(parts)] = {
|
||||
cachedAt: new Date().toISOString(),
|
||||
data,
|
||||
etag,
|
||||
};
|
||||
writeStore(store);
|
||||
}
|
||||
|
||||
export function clearOfflineCache(): void {
|
||||
const storage = safeLocalStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
storage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
async function readResponseText(response: Response): Promise<string> {
|
||||
try {
|
||||
return await response.text();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export async function cachedJsonRequest<T>(
|
||||
parts: readonly unknown[],
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
options?: {
|
||||
onUnauthorized?: () => T;
|
||||
},
|
||||
): Promise<T> {
|
||||
const cached = readCachedValue<T>(parts);
|
||||
const headers = new Headers(init?.headers);
|
||||
if (cached?.etag) {
|
||||
headers.set("If-None-Match", cached.etag);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
cache: "no-cache",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (response.status === 304) {
|
||||
if (!cached) {
|
||||
throw new Error("Cache validation returned 304 without a cached response.");
|
||||
}
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if ((response.status === 401 || response.status === 403) && options?.onUnauthorized) {
|
||||
return options.onUnauthorized();
|
||||
}
|
||||
throw new Error((await readResponseText(response)) || `Request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as T;
|
||||
writeCachedValue(parts, data, response.headers.get("etag") ?? undefined);
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (cached && error instanceof TypeError) {
|
||||
return cached.data;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { FormEvent, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import { clearOfflineCache } from "../lib/offlineCache";
|
||||
import { queryClient } from "../lib/queryClient";
|
||||
|
||||
export function AdminPage() {
|
||||
@@ -15,7 +16,8 @@ export function AdminPage() {
|
||||
setError(null);
|
||||
try {
|
||||
await api.adminLogin({ username, password });
|
||||
await queryClient.invalidateQueries({ queryKey: ["session"] });
|
||||
clearOfflineCache();
|
||||
queryClient.clear();
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unable to sign in");
|
||||
|
||||
@@ -33,16 +33,22 @@ export function GamePage() {
|
||||
queryKey: ["clips", teamId, playerId, "visible"],
|
||||
queryFn: () => api.listClips(teamId, playerId),
|
||||
enabled: Boolean(teamId && playerId),
|
||||
networkMode: "always",
|
||||
retry: 0,
|
||||
});
|
||||
const assignmentsQuery = useQuery({
|
||||
queryKey: ["assignments", selectedGameId, playerId],
|
||||
queryFn: () => api.listAssignments(selectedGameId, playerId),
|
||||
enabled: Boolean(selectedGameId && playerId),
|
||||
networkMode: "always",
|
||||
retry: 0,
|
||||
});
|
||||
const prepQuery = useQuery({
|
||||
queryKey: ["prep", selectedGameId],
|
||||
queryFn: () => api.prepareGame(selectedGameId),
|
||||
enabled: Boolean(selectedGameId),
|
||||
networkMode: "always",
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
|
||||
@@ -117,6 +117,7 @@ export function GamedayPage() {
|
||||
queryFn: () => api.listAssignments(resolvedSelectedGameId),
|
||||
enabled: Boolean(resolvedSelectedGameId),
|
||||
retry: 0,
|
||||
networkMode: "always",
|
||||
});
|
||||
|
||||
const preparedGame = resolvedSelectedGameId ? loadPreparedGame(resolvedSelectedGameId) : null;
|
||||
@@ -126,12 +127,16 @@ export function GamedayPage() {
|
||||
queryKey: ["teamsnap", "eventLineup", teamId, resolvedSelectedGameId],
|
||||
queryFn: () => teamsnapClient.loadEventLineupData(teamId, resolvedSelectedGameId),
|
||||
enabled: Boolean(teamId && resolvedSelectedGameId),
|
||||
networkMode: "always",
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const availabilityQuery = useQuery({
|
||||
queryKey: ["teamsnap", "availabilities", teamId, resolvedSelectedGameId],
|
||||
queryFn: () => teamsnapClient.loadAvailabilities(teamId, resolvedSelectedGameId),
|
||||
enabled: Boolean(teamId && resolvedSelectedGameId),
|
||||
networkMode: "always",
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const orderedMembers = useMemo(
|
||||
@@ -428,6 +433,8 @@ function LibraryClips({
|
||||
queryKey: ["clips", teamId, playerId, "visible"],
|
||||
queryFn: () => api.listClips(teamId, playerId),
|
||||
enabled: Boolean(teamId && playerId),
|
||||
networkMode: "always",
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
if (fallbackClipsQuery.isLoading) {
|
||||
|
||||
@@ -71,16 +71,22 @@ export function LibraryPage() {
|
||||
queryKey: ["assets", teamId, playerId],
|
||||
queryFn: () => api.listAssets(teamId, playerId),
|
||||
enabled: Boolean(teamId && playerId),
|
||||
networkMode: "always",
|
||||
retry: 0,
|
||||
});
|
||||
const clipsQuery = useQuery({
|
||||
queryKey: clipsQueryKey(teamId, playerId, true),
|
||||
queryFn: () => api.listClips(teamId, playerId, true),
|
||||
enabled: Boolean(teamId && playerId),
|
||||
networkMode: "always",
|
||||
retry: 0,
|
||||
});
|
||||
const pinsQuery = useQuery({
|
||||
queryKey: ["pins", teamId, playerId],
|
||||
queryFn: () => api.listPins(playerId),
|
||||
enabled: Boolean(playerId),
|
||||
networkMode: "always",
|
||||
retry: 0,
|
||||
});
|
||||
const orderedClips = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import { useWalkupContext } from "../hooks/useWalkupContext";
|
||||
import { clearOfflineCache } from "../lib/offlineCache";
|
||||
import { formatMemberName, formatTeamLabel } from "../lib/teamsnapHelpers";
|
||||
import { queryClient } from "../lib/queryClient";
|
||||
|
||||
@@ -12,8 +13,8 @@ export function ProfilePage() {
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: api.logout,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["session"] });
|
||||
await queryClient.removeQueries({ queryKey: ["teamsnap"] });
|
||||
clearOfflineCache();
|
||||
queryClient.clear();
|
||||
navigate("/signin");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -12,6 +12,24 @@ export default defineConfig(({ mode }) => {
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
includeAssets: ["icon.svg"],
|
||||
workbox: {
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: ({ url }) => url.pathname.startsWith("/api/media/files/"),
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "walkup-media",
|
||||
cacheableResponse: {
|
||||
statuses: [200],
|
||||
},
|
||||
expiration: {
|
||||
maxEntries: 200,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
manifest: {
|
||||
name: "Walkup",
|
||||
short_name: "Walkup",
|
||||
|
||||
Reference in New Issue
Block a user