{res.uri}
+ {res.uri}
{'result' in res && (
<>
Resolved
- {res.result.url}
+ {res.result.url}
{res.result.lastModified && (
<>
Last Modified
- {dayjs().to(res.result.lastModified)} ({res.result.lastModified.toISOString()})
+ {dayjs().to(res.result.lastModified)} ({res.result.lastModified.toISOString()})
>
)}
<>
Expires
- {
+ {
res.result.expiresAt ?
`${dayjs().to(res.result.expiresAt)} (${res.result.expiresAt.toISOString()})` :
'Never'
@@ -181,7 +182,7 @@ export function AssetInspector() {
{res.result.headers && (
<>
Raw Headers
- {JSON.stringify(Object.fromEntries(res.result.headers), null, 2)}
+ {JSON.stringify(Object.fromEntries(res.result.headers), null, 2)}
>
)}
>
@@ -242,21 +243,30 @@ export function AssetUploader() {
function handleUpload() {
setErrorMessages([]);
}
- async function handleUploadSuccess(file: UppyFile | undefined, response: SuccessResponse) {
- if (!file) {
- console.warn('Got upload success event without a file:', response)
- return;
- }
- const hash = sha256Cache.get(file.id) || sha256(await file.data.arrayBuffer());
- const watcloudURI = `watcloud://v1/sha256:${hash}?name=${encodeURIComponent(file.name)}`;
+ async function handleUploadSuccess(
+ file: UppyFile | undefined,
+ response: { body?: Record; status: number; bytesUploaded?: number; uploadURL?: string }
+ ) {
+ if (!file) {
+ console.warn('Got upload success event without a file:', response)
+ return;
+ }
+ // Since this function can't be async, use then/catch for async operations
+ const getHash = sha256Cache.get(file.id)
+ ? Promise.resolve(sha256Cache.get(file.id) as string)
+ : file.data.arrayBuffer().then((buf) => sha256(buf));
+ getHash.then((hash) => {
+ const fileName = file.name ?? 'unnamed';
+ const watcloudURI = `watcloud://v1/sha256:${hash}?name=${encodeURIComponent(fileName)}`;
console.log('Uploaded file:', file, 'Response:', response, 'watcloud URI:', watcloudURI);
setSuccessfulUploads((prev) => [{
- name: file.name,
+ name: fileName,
uri: watcloudURI,
}, ...prev]);
- }
- function handleUppyError(file: UppyFile | undefined, error: any) {
+ });
+ }
+ function handleUppyError(file: UppyFile | undefined, error: any) {
console.error('Failed upload:', file, "Error:", error, "Response status:", error.source?.status);
setErrorMessages((prev) => [`Failed to upload ${file?.name}: "${error.message}", response status: "${error.source?.status}", response body: "${error.source?.responseText}"`, ...prev]);
}
@@ -279,13 +289,13 @@ export function AssetUploader() {
note={`Maximum file size: ${bytesToSize(UPLOADER_MAX_FILE_SIZE, 0)}`}
width="100%"
theme={uppyTheme}
- showProgressDetails={true}
+ hideProgressDetails={false}
/>
Successful Uploads
{successfulUploads.map(({name, uri}) => (
{name}
- {uri}
+ {uri}
))}
{successfulUploads.length === 0 && (
diff --git a/components/blog.tsx b/components/blog.tsx
index 00260cd..64042fa 100644
--- a/components/blog.tsx
+++ b/components/blog.tsx
@@ -23,7 +23,7 @@ import { useRouter } from 'next/router';
import { MdxFile } from "nextra";
import { Link } from "nextra-theme-docs";
import { getPagesUnderRoute } from "nextra/context";
-import { useState, useEffect } from "react";
+import { useState, useEffect, useMemo, useRef } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import Picture from "./picture";
@@ -48,25 +48,38 @@ export function BlogIndex() {
const router = useRouter();
const locale = router.locale || websiteConfig.default_locale;
const activeTag = (router.query.tag as string | undefined)?.trim();
- let tagCounts: Record = {};
+ const hasRedirectedEmptyTag = useRef(false);
+ const hasRedirectedInvalidTag = useRef(false);
+ const lastActiveTag = useRef(activeTag);
- // Get all posts from route
- const allPosts = getPagesUnderRoute("/blog").filter((page) => {
- const frontMatter = (page as MdxFile).frontMatter || {};
- // Get tag counts for the tag bar
- if (frontMatter.tags && Array.isArray(frontMatter.tags)) {
- frontMatter.tags.forEach((tag: string) => {
- tagCounts[tag] = (tagCounts[tag] || 0) + 1;
- });
- }
- if (frontMatter.hidden) {return null}
- return frontMatter;
- });
+ // Get all posts from route and calculate tag counts
+ const { allPosts, tagCounts } = useMemo(() => {
+ const tagCounts: Record = {};
+ const allPosts = getPagesUnderRoute("/blog").filter((page) => {
+ const frontMatter = (page as MdxFile).frontMatter || {};
+ // Get tag counts for the tag bar
+ if (frontMatter.tags && Array.isArray(frontMatter.tags)) {
+ frontMatter.tags.forEach((tag: string) => {
+ tagCounts[tag] = (tagCounts[tag] || 0) + 1;
+ });
+ }
+ if (frontMatter.hidden) {return null}
+ return frontMatter;
+ });
+ return { allPosts, tagCounts };
+ }, []);
// Redirect to main blog page if no tag specified or empty tag
// (but only after router is ready and we've attempted to parse the tag)
useEffect(() => {
+ // Reset redirect flag when activeTag changes
+ if (activeTag !== lastActiveTag.current) {
+ hasRedirectedEmptyTag.current = false;
+ lastActiveTag.current = activeTag;
+ }
+
if (router.isReady && !activeTag &&
+ !hasRedirectedEmptyTag.current &&
(
!router.query.tag ||
(typeof router.query.tag === 'string' && router.query.tag.trim() === '') ||
@@ -75,18 +88,23 @@ export function BlogIndex() {
router.asPath.endsWith('?tag=')
)
) {
- router.push('/blog')
- } // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [router.isReady, activeTag, router.query.tag, router.asPath])
- // excluding router object from deps to prevent infinite loop (it changes on every navigation)
+ hasRedirectedEmptyTag.current = true;
+ router.push('/blog');
+ }
+ }, [router, activeTag])
// Redirect if tag has no posts
useEffect(() => {
- if (router.isReady && activeTag && (!tagCounts[activeTag] || tagCounts[activeTag] === 0)) {
- router.push('/blog')
- } // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [router.isReady, activeTag])
- // excluding router object from deps to prevent infinite loop (it changes on every navigation)
+ // Reset redirect flag when activeTag changes to a valid tag
+ if (activeTag && tagCounts[activeTag] && tagCounts[activeTag] > 0) {
+ hasRedirectedInvalidTag.current = false;
+ }
+
+ if (router.isReady && activeTag && !hasRedirectedInvalidTag.current && (!tagCounts[activeTag] || tagCounts[activeTag] === 0)) {
+ hasRedirectedInvalidTag.current = true;
+ router.push('/blog');
+ }
+ }, [router, activeTag, tagCounts])
// Filter blogs by tag
const filteredPosts = allPosts.filter((page) => {
@@ -219,8 +237,8 @@ export function BlogIndex() {
}
const subscribeFormSchema = z.object({
- email: z.string().email({
- message: "Please enter a valid email.",
+ email: z.email({
+ error: "Please enter a valid email.",
}),
});
diff --git a/components/github.tsx b/components/github.tsx
index dfc7266..124b3fb 100644
--- a/components/github.tsx
+++ b/components/github.tsx
@@ -14,7 +14,7 @@ import { useState } from "react";
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Input } from "./ui/input";
-import { useMDXComponents } from "nextra-theme-docs";
+import { useMDXComponents } from "nextra/mdx";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Link } from "nextra-theme-docs"
@@ -217,7 +217,7 @@ export function UsernameToID() {
{globalNodeID && (
Global Node ID
- {globalNodeID}
+ {globalNodeID}
)}
{rawData && (
@@ -228,7 +228,7 @@ export function UsernameToID() {
Raw data
- {JSON.stringify(rawData, null, 2)}
+ {JSON.stringify(rawData, null, 2)}
+
{machine.ssh_host_keys.join('\n')}
diff --git a/components/maintenance-email-generator.tsx b/components/maintenance-email-generator.tsx
index 5684573..de0d10e 100644
--- a/components/maintenance-email-generator.tsx
+++ b/components/maintenance-email-generator.tsx
@@ -314,13 +314,13 @@ export default function MaintenanceEmailGenerator() {
Email (Plain Text)
-
+
{startTimeError ? "Please enter a valid future start time to generate the email." : plainTextEmail}
Discord (Markdown)
-
+
{startTimeError ? "Please enter a valid future start time to generate the Discord message." : markdownDiscord}
diff --git a/components/onboarding-form.tsx b/components/onboarding-form.tsx
index 0b1811c..85a8406 100644
--- a/components/onboarding-form.tsx
+++ b/components/onboarding-form.tsx
@@ -19,7 +19,7 @@ import {
} from "@rjsf/validator-ajv8";
import { JSONSchema7 } from "json-schema";
import { Loader2 } from "lucide-react";
-import { useState, useEffect } from "react";
+import { useState, useEffect, useRef } from "react";
import { lookupStringMDX, userSchemaStrings } from "@/lib/data";
import { Button } from "@/components/ui/button";
import {
@@ -129,17 +129,22 @@ export default function OnboardingForm() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [showAdvancedFields, setShowAdvancedFields] = useState(false);
const [formData, _setFormData] = useState(null as any);
+ const isInitializedRef = useRef(false);
+ const hasShownToastRef = useRef(false);
// Use session storage to persist form data in case of accidental navigation.
// We don't prevent navigation, because that's an anti-pattern:
// https://reactrouter.com/en/main/hooks/use-blocker
useEffect(() => {
- if (!isReady) {
+ if (!isReady || isInitializedRef.current) {
// The router is not ready yet, so the query params are not available.
// https://github.com/vercel/next.js/discussions/11484#discussioncomment-356055
+ // Or we've already initialized the form data.
return;
}
+ isInitializedRef.current = true;
+
try {
const savedFormState = getFormState();
const savedFormData = savedFormState.formData;
@@ -175,19 +180,26 @@ export default function OnboardingForm() {
}
if (initialFormData) {
- _setFormData(initialFormData);
- if (dataSource === "sessionStorage") {
- toast.success("Successfully restored form data from session storage.");
- } else if (dataSource === "queryParam") {
- toast.success("Successfully loaded form data from query params.");
- }
+ // Use flushSync to batch the state update before showing toast
+ // This ensures the state is updated synchronously before side effects
+ queueMicrotask(() => {
+ _setFormData(initialFormData);
+ if (!hasShownToastRef.current) {
+ hasShownToastRef.current = true;
+ if (dataSource === "sessionStorage") {
+ toast.success("Successfully restored form data from session storage.");
+ } else if (dataSource === "queryParam") {
+ toast.success("Successfully loaded form data from query params.");
+ }
+ }
+ });
}
} catch (e) {
console.error("Failed to load saved form data", e);
toast.error("Failed to load saved form data. Please see the browser console for more information.");
}
- }, [isReady, initialFormDataB64FromParam, expiresAtFromParam]);
+ }, [isReady, initialFormDataB64FromParam, expiresAtFromParam, _setFormData]);
function setFormData(data: any) {
_setFormData(data);
@@ -220,7 +232,7 @@ export default function OnboardingForm() {
setAlertDescription("Below is the raw data from the form. You can send this to the WATcloud team for debugging purposes.");
setAlertBody(
<>
-
+
{JSON.stringify(postprocessFormData(formData), null, 2)}
@@ -237,7 +249,7 @@ export default function OnboardingForm() {
const editLink = `${window.location.origin}${window.location.pathname}?${INITIAL_FORM_DATA_QUERY_PARAM}=${b64EncodeURI(JSON.stringify(postprocessFormData(formData)))}`;
setAlertBody(
<>
-
+
{editLink}
@@ -328,7 +340,7 @@ export default function OnboardingForm() {
Successfully submitted registration request for {slug}! We will review your request and get back to you shortly.
Your request ID is:
-
+
{requestID}
diff --git a/components/page-index.tsx b/components/page-index.tsx
index 502a638..591ec69 100644
--- a/components/page-index.tsx
+++ b/components/page-index.tsx
@@ -1,7 +1,7 @@
import {
getPagesUnderRoute,
} from "nextra/context";
-import { Card, Cards } from "nextra/components";
+import { Cards } from "nextra/components";
import { BookMarkedIcon } from "lucide-react";
function PageIndex({
@@ -16,18 +16,19 @@ function PageIndex({
{
pages.map((page, i) => {
// Skip directories with no index page
- if (page.kind !== "MdxPage") return null;
+ // In Nextra v3, Page doesn't expose a `kind` field; use presence of `frontMatter` to detect MDX pages
+ if (!('frontMatter' in page)) return null;
const title = page.meta?.title || page.name;
const route = page.route;
return (
- }
title={title}
href={route}
- >{null}
+ >{null}
);
})
}
diff --git a/components/profile-editor.tsx b/components/profile-editor.tsx
index a0c278f..dd0aeb0 100644
--- a/components/profile-editor.tsx
+++ b/components/profile-editor.tsx
@@ -27,8 +27,8 @@ import { Input } from "@/components/ui/input";
import { useState } from "react";
const formSchema = z.object({
- email: z.string().email({
- message: "Please enter a valid email.",
+ email: z.email({
+ error: "Please enter a valid email.",
}),
});
diff --git a/components/rjsf-templates.tsx b/components/rjsf-templates.tsx
index 3e51be5..c54619c 100644
--- a/components/rjsf-templates.tsx
+++ b/components/rjsf-templates.tsx
@@ -28,7 +28,7 @@ import {
titleId,
} from "@rjsf/utils";
import { PlusIcon, Trash2Icon } from "lucide-react";
-import { ChangeEvent, FocusEvent, useCallback } from "react";
+import { ChangeEvent, FocusEvent, useCallback, useMemo } from "react";
import { Input } from "@/components/ui/input";
const REQUIRED_ELEM = (
@@ -122,16 +122,6 @@ function CustomArrayFieldTemplate({
onAddClick,
}: ArrayFieldTemplateProps) {
const uiOptions = getUiOptions(uiSchema);
- const ArrayFieldDescriptionTemplate = getTemplate(
- "ArrayFieldDescriptionTemplate",
- registry,
- uiOptions
- );
- const ArrayFieldItemTemplate = getTemplate(
- "ArrayFieldItemTemplate",
- registry,
- uiOptions
- );
const description = uiOptions.description || schema.description;
return (