fix: harden workshop availability
This commit is contained in:
28 files changed
+671
-279
No files matched your search
@@ -24,10 +24,10 @@ Navigate to `Management` → `Tools` → `Backup`. You must have **admin** permi
|
||||
|
||||
Click the **Export** button in the export card. You'll be prompted to type `I understand` to confirm. Once confirmed, a ZIP file downloads containing:
|
||||
|
||||
| File | Description |
|
||||
| -------------------- | ------------------------------------------------------------------- |
|
||||
| `db.sqlite` | Full SQLite database with all entities |
|
||||
| `metadata.json` | Backup metadata: Homarr version, creation timestamp, encryption key |
|
||||
| File | Description |
|
||||
| --------------- | ------------------------------------------------------------------- |
|
||||
| `db.sqlite` | Full SQLite database with all entities |
|
||||
| `metadata.json` | Backup metadata: Homarr version, creation timestamp, encryption key |
|
||||
|
||||
The backup includes everything: apps, integrations, secrets, boards, users, groups, permissions, search engines, custom widgets, and server settings. (It just backs-up the whole database)
|
||||
|
||||
@@ -54,6 +54,7 @@ Type `I understand` to acknowledge that restoring will replace your current data
|
||||
The backup ZIP is sent to the server, validated, and applied. After a successful restore:
|
||||
|
||||
- **The Next.js server restarts automatically** to reload the new database state
|
||||
- **Server-side database migrations run before Next.js restarts**, including migrations added after the backup was created
|
||||
- Nginx and Redis (if running in Docker) stay up - only the Next.js process restarts
|
||||
- The page reloads once the server is ready
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ Workshop entries are created by community members. Review widget endpoints, temp
|
||||
|
||||
In Homarr, open **Management → Workshop** to browse, search, filter, inspect, download, and install creations. The custom-widget management page and a board's custom CSS settings also provide contextual Workshop actions.
|
||||
|
||||
Recently loaded listings and details are saved in the browser for up to 24 hours. If Workshop is temporarily unreachable, Homarr shows the saved catalog with a clear offline notice; browsing the rest of Homarr is unaffected. Publishing, voting, reporting, and installation remain unavailable until the connection returns.
|
||||
|
||||
- Widgets are validated against `homarr-custom-widget-v2` before installation. API keys and other secrets are not included and must be configured locally.
|
||||
- CSS is loaded into the board editor for review. It is not saved until you choose **Save changes**.
|
||||
- The public website never executes submitted widget templates or CSS.
|
||||
|
||||
@@ -3,6 +3,11 @@ import type { Config } from "@docusaurus/types";
|
||||
import { themes as prismThemes } from "prism-react-renderer";
|
||||
const a11yEmoji = require("@fec/remark-a11y-emoji");
|
||||
|
||||
const workshopApiUrl =
|
||||
process.env.NODE_ENV !== "production"
|
||||
? (process.env.WORKSHOP_API_URL ?? process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? "https://workshop.homarr.dev")
|
||||
: "https://workshop.homarr.dev";
|
||||
|
||||
const config: Config = {
|
||||
title: "Homarr documentation",
|
||||
tagline: "A simple yet powerful dashboard for your server.",
|
||||
@@ -17,8 +22,7 @@ const config: Config = {
|
||||
locales: ["en"],
|
||||
},
|
||||
customFields: {
|
||||
workshopApiUrl:
|
||||
process.env.WORKSHOP_API_URL ?? process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? "https://workshop.homarr.dev",
|
||||
workshopApiUrl,
|
||||
},
|
||||
onBrokenLinks: "throw",
|
||||
onBrokenAnchors: "throw",
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tailwindcss/postcss": "^4.3.2",
|
||||
"@tanstack/query-async-storage-persister": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-query-persist-client": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"docusaurus-plugin-image-zoom": "^3.0.1",
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
WORKSHOP_API_URL,
|
||||
WorkshopClient,
|
||||
type WorkshopAccountState,
|
||||
type WorkshopModerationAction,
|
||||
type WorkshopReport,
|
||||
type WorkshopRole,
|
||||
type WorkshopUser,
|
||||
} from "@homarr/workshop";
|
||||
|
||||
import { WorkshopQueryProvider } from "./WorkshopQueryProvider";
|
||||
import styles from "./workshop.module.css";
|
||||
|
||||
type PendingAction =
|
||||
@@ -19,50 +19,77 @@ type PendingAction =
|
||||
| { kind: "role"; id: string; role: WorkshopRole };
|
||||
|
||||
export function WorkshopAdmin() {
|
||||
return (
|
||||
<WorkshopQueryProvider>
|
||||
<WorkshopAdminContent />
|
||||
</WorkshopQueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopAdminContent() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
const client = useMemo(
|
||||
() => new WorkshopClient((siteConfig.customFields?.workshopApiUrl as string | undefined) ?? WORKSHOP_API_URL),
|
||||
[siteConfig],
|
||||
);
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [reports, setReports] = useState<WorkshopReport[]>([]);
|
||||
const [users, setUsers] = useState<WorkshopUser[]>([]);
|
||||
const [actions, setActions] = useState<WorkshopModerationAction[]>([]);
|
||||
const [pending, setPending] = useState<PendingAction | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const current = await client.refreshAuth();
|
||||
setUser(current);
|
||||
if (!current || (current.role !== "moderator" && current.role !== "admin")) return;
|
||||
try {
|
||||
const [nextReports, nextUsers, nextActions] = await Promise.all([
|
||||
const canModerate = user?.role === "moderator" || user?.role === "admin";
|
||||
const moderationQuery = useQuery({
|
||||
queryKey: ["workshop", "moderation"],
|
||||
queryFn: async () => {
|
||||
const [reports, users, actions] = await Promise.all([
|
||||
client.listReports(),
|
||||
client.listUsers(),
|
||||
client.listModerationActions(),
|
||||
]);
|
||||
setReports(nextReports);
|
||||
setUsers(nextUsers);
|
||||
setActions(nextActions);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Could not load moderation data");
|
||||
}
|
||||
}, [client]);
|
||||
return { reports, users, actions };
|
||||
},
|
||||
enabled: canModerate,
|
||||
});
|
||||
const reports = moderationQuery.data?.reports ?? [];
|
||||
const users = moderationQuery.data?.users ?? [];
|
||||
const actions = moderationQuery.data?.actions ?? [];
|
||||
|
||||
useEffect(() => client.subscribeToAuth(setUser), [client]);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
void client
|
||||
.refreshAuth()
|
||||
.then(setUser)
|
||||
.catch((caught: unknown) =>
|
||||
setError(caught instanceof Error ? caught.message : "Workshop authentication is unavailable"),
|
||||
)
|
||||
.finally(() => setAuthLoading(false));
|
||||
}, [client]);
|
||||
|
||||
if (authLoading)
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<WorkshopAdminSkeleton />
|
||||
</main>
|
||||
);
|
||||
if (!user)
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<h1>Workshop moderation</h1>
|
||||
<p>Sign in with a promoted GitHub account to continue.</p>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="button button--primary"
|
||||
onClick={async () => {
|
||||
await client.signInWithGitHub();
|
||||
await load();
|
||||
setError("");
|
||||
try {
|
||||
setUser(await client.signInWithGitHub());
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "GitHub sign-in is unavailable");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Sign in with GitHub
|
||||
@@ -87,9 +114,10 @@ export function WorkshopAdmin() {
|
||||
<p>Review reports and apply the smallest action needed. Every action requires a reason and is recorded.</p>
|
||||
</div>
|
||||
</header>
|
||||
{error && (
|
||||
{(error || moderationQuery.isError) && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
{error || "Moderation data is unavailable. Try again when Workshop reconnects."}{" "}
|
||||
{moderationQuery.isError && <button onClick={() => void moderationQuery.refetch()}>Try again</button>}
|
||||
</div>
|
||||
)}
|
||||
<section className={styles.adminSection}>
|
||||
@@ -235,7 +263,7 @@ export function WorkshopAdmin() {
|
||||
else if (pending.kind === "state") await client.updateAccountState(pending.id, pending.state, reason);
|
||||
else await client.updateRole(pending.id, pending.role, reason);
|
||||
setPending(null);
|
||||
await load();
|
||||
await moderationQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -243,6 +271,15 @@ export function WorkshopAdmin() {
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopAdminSkeleton() {
|
||||
return (
|
||||
<div aria-label="Loading Workshop moderation">
|
||||
<h1>Workshop moderation</h1>
|
||||
<p>Loading account and moderation data…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReasonDialog({
|
||||
action,
|
||||
onClose,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import Link from "@docusaurus/Link";
|
||||
import { useHistory, useLocation } from "@docusaurus/router";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import {
|
||||
MAX_CONTENT_LENGTH,
|
||||
MAX_SCREENSHOTS,
|
||||
WORKSHOP_API_URL,
|
||||
WorkshopClient,
|
||||
type WorkshopSubmissionDetail,
|
||||
@@ -22,7 +24,9 @@ import {
|
||||
type WorkshopUser,
|
||||
workshopExportFilename,
|
||||
} from "@homarr/workshop";
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { WorkshopQueryProvider } from "./WorkshopQueryProvider";
|
||||
import styles from "./workshop.module.css";
|
||||
|
||||
const useWorkshop = () => {
|
||||
@@ -43,45 +47,55 @@ const saveContent = (submission: WorkshopSubmissionDetail) => {
|
||||
};
|
||||
|
||||
export function WorkshopApp() {
|
||||
return (
|
||||
<WorkshopQueryProvider>
|
||||
<WorkshopAppContent />
|
||||
</WorkshopQueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopAppContent() {
|
||||
const client = useWorkshop();
|
||||
const queryClient = useQueryClient();
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [items, setItems] = useState<WorkshopSubmissionSummary[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [type, setType] = useState<WorkshopSubmissionType | "all">("all");
|
||||
const [sort, setSort] = useState<"top" | "newest">("top");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [mine, setMine] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
useEffect(() => client.subscribeToAuth(setUser), [client]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
await client.refreshAuth();
|
||||
const result = await client.list({ page, type, sort, search, author: mine ? client.currentUser?.id : undefined });
|
||||
setItems(result.items);
|
||||
setTotalPages(result.totalPages);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Workshop is unavailable");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client, mine, page, search, sort, type]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => void load(), 250);
|
||||
void client.refreshAuth().catch(() => undefined);
|
||||
}, [client]);
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => setDebouncedSearch(search), 250);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [load]);
|
||||
}, [search]);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["workshop", "list", page, type, sort, debouncedSearch, mine ? user?.id : null],
|
||||
queryFn: ({ signal }) =>
|
||||
client.list({
|
||||
page,
|
||||
type,
|
||||
sort,
|
||||
search: debouncedSearch,
|
||||
author: mine ? user?.id : undefined,
|
||||
signal,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const items = listQuery.data?.items ?? [];
|
||||
const totalPages = listQuery.data?.totalPages ?? 1;
|
||||
|
||||
const signIn = async () => {
|
||||
try {
|
||||
await client.signInWithGitHub();
|
||||
await load();
|
||||
await queryClient.invalidateQueries({ queryKey: ["workshop"] });
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Sign-in failed");
|
||||
}
|
||||
@@ -179,12 +193,17 @@ export function WorkshopApp() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
{(error || listQuery.isError || listQuery.fetchStatus === "paused") && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error} <button onClick={() => void load()}>Try again</button>
|
||||
<strong>{items.length ? "Showing saved Workshop results." : "Workshop is unavailable."}</strong>{" "}
|
||||
{error ||
|
||||
(items.length
|
||||
? "Some details and actions may be unavailable until the service reconnects."
|
||||
: "Homarr and the documentation remain available while the community service is offline.")}{" "}
|
||||
<button onClick={() => void listQuery.refetch()}>Try again</button>
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
{listQuery.isPending ? (
|
||||
<WorkshopSkeleton />
|
||||
) : items.length ? (
|
||||
<div className={styles.grid}>
|
||||
@@ -192,7 +211,7 @@ export function WorkshopApp() {
|
||||
<WorkshopCard client={client} item={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
) : listQuery.isError || listQuery.fetchStatus === "paused" ? null : (
|
||||
<section className={styles.empty}>
|
||||
<h2>No creations found</h2>
|
||||
<p>Adjust the filters, or share the first creation in this category.</p>
|
||||
@@ -218,7 +237,7 @@ export function WorkshopApp() {
|
||||
onClose={() => setShowForm(false)}
|
||||
onSaved={async () => {
|
||||
setShowForm(false);
|
||||
await load();
|
||||
await queryClient.invalidateQueries({ queryKey: ["workshop"] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -258,42 +277,64 @@ function WorkshopCard({ client, item }: { client: WorkshopClient; item: Workshop
|
||||
}
|
||||
|
||||
export function WorkshopDetailRoute() {
|
||||
return (
|
||||
<WorkshopQueryProvider>
|
||||
<WorkshopDetailRouteContent />
|
||||
</WorkshopQueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopDetailRouteContent() {
|
||||
const client = useWorkshop();
|
||||
const queryClient = useQueryClient();
|
||||
const location = useLocation();
|
||||
const id = location.pathname.split("/").filter(Boolean).at(-1) ?? "";
|
||||
const history = useHistory();
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [submission, setSubmission] = useState<WorkshopSubmissionDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [reporting, setReporting] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [moderating, setModerating] = useState(false);
|
||||
const [actionError, setActionError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setSubmission(await client.get(id));
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Submission not found");
|
||||
}
|
||||
}, [client, id]);
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ["workshop", "detail", id],
|
||||
queryFn: ({ signal }) => client.get(id, signal),
|
||||
enabled: id.length > 0,
|
||||
});
|
||||
const voteMutation = useMutation({
|
||||
mutationKey: ["workshop", "vote", id],
|
||||
mutationFn: async () => {
|
||||
await (client.currentUser ?? client.signInWithGitHub());
|
||||
return client.vote(id, 1);
|
||||
},
|
||||
onSuccess: async () => queryClient.invalidateQueries({ queryKey: ["workshop", "detail", id] }),
|
||||
onError: (caught) => setActionError(caught instanceof Error ? caught.message : "The vote could not be saved."),
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationKey: ["workshop", "delete", id],
|
||||
mutationFn: () => client.delete(id),
|
||||
onSuccess: () => history.push("/workshop"),
|
||||
onError: (caught) =>
|
||||
setActionError(caught instanceof Error ? caught.message : "The submission could not be deleted."),
|
||||
});
|
||||
useEffect(() => client.subscribeToAuth(setUser), [client]);
|
||||
useEffect(() => {
|
||||
void client.refreshAuth();
|
||||
void load();
|
||||
}, [client, load]);
|
||||
}, [client]);
|
||||
|
||||
if (error)
|
||||
if (detailQuery.isError)
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
This creation is not saved on this device and Workshop could not be reached.{" "}
|
||||
<button onClick={() => void detailQuery.refetch()}>Try again</button>
|
||||
</div>
|
||||
<Link to="/workshop">Back to Workshop</Link>
|
||||
</main>
|
||||
);
|
||||
if (!submission) return <WorkshopSkeleton />;
|
||||
if (!detailQuery.data) return <WorkshopSkeleton />;
|
||||
|
||||
const requireAuth = async () => client.currentUser ?? client.signInWithGitHub();
|
||||
const submission = detailQuery.data;
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<Link to="/workshop">← Back to Workshop</Link>
|
||||
@@ -329,10 +370,10 @@ export function WorkshopDetailRoute() {
|
||||
</button>
|
||||
<button
|
||||
className="button button--secondary"
|
||||
onClick={async () => {
|
||||
await requireAuth();
|
||||
await client.vote(submission.id, 1);
|
||||
await load();
|
||||
disabled={voteMutation.isPending}
|
||||
onClick={() => {
|
||||
setActionError("");
|
||||
voteMutation.mutate();
|
||||
}}
|
||||
>
|
||||
Upvote ({submission.upvotes})
|
||||
@@ -347,10 +388,11 @@ export function WorkshopDetailRoute() {
|
||||
</button>
|
||||
<button
|
||||
className="button button--danger"
|
||||
onClick={async () => {
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm("Delete this submission permanently?")) {
|
||||
await client.delete(submission.id);
|
||||
history.push("/workshop");
|
||||
setActionError("");
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -364,6 +406,11 @@ export function WorkshopDetailRoute() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{actionError && (
|
||||
<div className={styles.error} role="alert">
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
<section>
|
||||
<h2>Source</h2>
|
||||
<p className={styles.sourceHint}>
|
||||
@@ -388,7 +435,7 @@ export function WorkshopDetailRoute() {
|
||||
onClose={() => setEditing(false)}
|
||||
onSaved={async () => {
|
||||
setEditing(false);
|
||||
await load();
|
||||
await queryClient.invalidateQueries({ queryKey: ["workshop", "detail", id] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -484,6 +531,11 @@ function SubmissionForm({
|
||||
const [removedScreenshots, setRemovedScreenshots] = useState<string[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const retainedScreenshotCount = (existing?.screenshots.length ?? 0) - removedScreenshots.length;
|
||||
const remainingScreenshotSlots = Math.max(0, MAX_SCREENSHOTS - retainedScreenshotCount);
|
||||
useEffect(() => {
|
||||
setScreenshots((current) => current.slice(0, remainingScreenshotSlots));
|
||||
}, [remainingScreenshotSlots]);
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setPending(true);
|
||||
@@ -544,6 +596,7 @@ function SubmissionForm({
|
||||
<textarea
|
||||
className={styles.editor}
|
||||
required
|
||||
maxLength={MAX_CONTENT_LENGTH}
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
/>
|
||||
@@ -553,12 +606,14 @@ function SubmissionForm({
|
||||
<textarea maxLength={2000} value={changelog} onChange={(event) => setChangelog(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Screenshots (PNG, JPG, or WebP, up to 5)
|
||||
Screenshots (PNG, JPG, or WebP, {remainingScreenshotSlots} available)
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
onChange={(event) => setScreenshots(Array.from(event.target.files ?? []).slice(0, 5))}
|
||||
onChange={(event) =>
|
||||
setScreenshots(Array.from(event.target.files ?? []).slice(0, remainingScreenshotSlots))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{existing?.screenshots.map((screenshot) => (
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { type PropsWithChildren, useState } from "react";
|
||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
|
||||
const maxAge = 1000 * 60 * 60 * 24;
|
||||
|
||||
export function WorkshopQueryProvider({ children }: PropsWithChildren) {
|
||||
const [client] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: maxAge,
|
||||
networkMode: "offlineFirst",
|
||||
retry: 1,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
},
|
||||
mutations: { networkMode: "online", retry: false },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const [persister] = useState(() =>
|
||||
createAsyncStoragePersister({
|
||||
key: "homarr-workshop-website-cache-v1",
|
||||
storage: window.localStorage,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
client={client}
|
||||
persistOptions={{
|
||||
persister,
|
||||
maxAge,
|
||||
buster: "workshop-v1",
|
||||
dehydrateOptions: { shouldDehydrateQuery: (query) => query.state.status === "success" },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PersistQueryClientProvider>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user