From 9f3f86d78915a5e119681dd2323c960b09448d50 Mon Sep 17 00:00:00 2001 From: Xavier Mirabelli-Montan Date: Wed, 24 Apr 2024 16:18:10 +0100 Subject: [PATCH] Cleaning up the create page logic, tweaking caching, and cleaning up actions --- frontend/packages/utils/src/Utils.js | 12 ++ .../development/app/[...slug]/page.tsx | 4 +- .../development/app/admin/content/page.tsx | 187 ++++++++++-------- .../development/app/api/revalidate/route.ts | 2 +- .../development/app/create/page/new/page.tsx | 150 ++++++++------ .../app/edit/[...puckPath]/client.tsx | 27 +-- .../development/{ => app}/favicon.ico | Bin frontend/starters/development/app/layout.tsx | 8 +- .../components/admin/Header/Header.tsx | 62 +++--- .../components/drupal/BasicPage.tsx | 7 +- .../components/ui/dropdown-menu.tsx | 40 ++++ .../development/components/ui/sonner.tsx | 31 +++ .../development/lib/trigger-revalidation.ts | 35 ++++ frontend/starters/development/middleware.ts | 11 +- frontend/starters/development/package.json | 2 + .../starters/development/styles/globals.css | 24 +++ frontend/yarn.lock | 22 +++ 17 files changed, 435 insertions(+), 189 deletions(-) rename frontend/starters/development/{ => app}/favicon.ico (100%) create mode 100644 frontend/starters/development/components/ui/sonner.tsx create mode 100644 frontend/starters/development/lib/trigger-revalidation.ts diff --git a/frontend/packages/utils/src/Utils.js b/frontend/packages/utils/src/Utils.js index 8600535..f1ffa2f 100644 --- a/frontend/packages/utils/src/Utils.js +++ b/frontend/packages/utils/src/Utils.js @@ -15,3 +15,15 @@ export const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1).toLowerCase() export const isBrowser = () => typeof window !== "undefined" + +export const formatDate = (dateString) => { + const date = new Date(dateString) + return date.toLocaleString("en-US", { + year: "numeric", // 2021, 2022, ... + month: "long", // January, February, ... + day: "numeric", // 1, 2, 3, ... + hour: "numeric", // 12 AM, 1 PM, ... + minute: "numeric", // 00, 01, 02, ... + second: "numeric", // 00, 01, 02, ... + }) +} diff --git a/frontend/starters/development/app/[...slug]/page.tsx b/frontend/starters/development/app/[...slug]/page.tsx index 6609562..50d742d 100644 --- a/frontend/starters/development/app/[...slug]/page.tsx +++ b/frontend/starters/development/app/[...slug]/page.tsx @@ -110,7 +110,7 @@ export default async function NodePage({ searchParams, }: NodePageProps) { const isDraftMode = draftMode().isEnabled - + const path = `/${slug.join("/")}` let node try { node = await getNode(slug) @@ -126,7 +126,7 @@ export default async function NodePage({ return ( <> - {node.type === "node--page" && } + {node.type === "node--page" && } {node.type === "node--article" &&
} ) diff --git a/frontend/starters/development/app/admin/content/page.tsx b/frontend/starters/development/app/admin/content/page.tsx index ce24046..7a52695 100644 --- a/frontend/starters/development/app/admin/content/page.tsx +++ b/frontend/starters/development/app/admin/content/page.tsx @@ -1,14 +1,7 @@ import Link from "next/link" -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card" -import { MoreHorizontal } from "lucide-react" +import { Card, CardContent, CardFooter } from "@/components/ui/card" +import { MoreHorizontal, ArrowUpRight } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -16,6 +9,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuDeleteItem, DropdownMenuLabel, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" @@ -30,10 +24,17 @@ import { import { drupal } from "@/lib/drupal" import type { DrupalNode, JsonApiParams } from "next-drupal" +import { unstable_noStore as noStore } from "next/cache" +import { formatDate } from "@powerstack/utils" +import { toast } from "sonner" -async function getNodes(type: string) { +export const dynamic = "force-dynamic" - const params: JsonApiParams = {} +async function getNodes(type: string) { + noStore() + const params: JsonApiParams = { + sort: "-changed", + } if (type === "node--article") { params.include = "field_image,uid" } @@ -47,110 +48,134 @@ async function getNodes(type: string) { }) if (!resource) { - throw new Error( - `Failed to fetch resource:`, - { - cause: "DrupalError", - } - ) + throw new Error(`Failed to fetch resource:`, { + cause: "DrupalError", + }) } return resource } type NodePageParams = { - slug: string[] - } - type NodePageProps = { - params: NodePageParams - searchParams: { [key: string]: string | string[] | undefined } - } - -export async function generateMetadata() { - - return { - title: `Admin: Content`, - } - } + slug: string[] +} +type NodePageProps = { + params: NodePageParams + searchParams: { [key: string]: string | string[] | undefined } +} export async function ContentList() { - let nodes + async function fetchNodes() { try { - nodes = await getNodes('node--page') + const fetchedNodes = await getNodes("node--page") + return fetchedNodes } catch (e) { - // If we fail to fetch the node, don't return any metadata. - return {} + return e } - const tableRows = nodes.map(node => ( - - - {node.title} - - - {node.status ? Pubished : Unpublished } - - {node.created} - - {node.changed} - - - - - - - - Actions - View - Edit - Delete - - - - - )) + } + const nodes = await fetchNodes() + + const tableRows = nodes.map((node) => ( + + + + {node.title} + + + + {node.status ? ( + Published + ) : ( + Unpublished + )} + + {formatDate(node.created)} + {formatDate(node.changed)} + + + + + + + Actions + + View + + + Edit + + Delete + + + + + )) return ( - - + <> + Title Status - Created - - Updated - - - Actions - + Created + Updated + Actions - - {tableRows} - + {tableRows}
- Showing {nodes.length} pages + Showing {nodes.length} pages. If you need more robust + content filtering visit{" "} + + here + + + .
-
+ ) } - -export default function Dashboard() { +export default async function Dashboard() { return (

Content

- + + +
) diff --git a/frontend/starters/development/app/api/revalidate/route.ts b/frontend/starters/development/app/api/revalidate/route.ts index 9882273..391416d 100644 --- a/frontend/starters/development/app/api/revalidate/route.ts +++ b/frontend/starters/development/app/api/revalidate/route.ts @@ -7,7 +7,7 @@ async function handler(request: NextRequest) { const secret = searchParams.get("secret") // Validate secret. - if (secret !== process.env.DRUPAL_REVALIDATE_SECRET) { + if (secret !== process.env.NEXT_REVALIDATE_SECRET) { return new Response("Invalid secret.", { status: 401 }) } diff --git a/frontend/starters/development/app/create/page/new/page.tsx b/frontend/starters/development/app/create/page/new/page.tsx index 4980938..5e58032 100644 --- a/frontend/starters/development/app/create/page/new/page.tsx +++ b/frontend/starters/development/app/create/page/new/page.tsx @@ -5,88 +5,112 @@ import "@measured/puck/puck.css" import config from "@/puck.config" import { drupal } from "@/lib/drupal" import { drupalFieldPrefix } from "@powerstack/utils" +import { useRouter } from "next/navigation" +import { toast } from "sonner" +import { useState } from "react" export default function Page({ path, data }: { path: string; data: Data }) { const backendUrl = process.env.NEXT_PUBLIC_DRUPAL_HOST - + const [loading, setLoading] = useState(false) + const router = useRouter() return ( { - async function processBlocks(data) { - try { - const blocks = await Promise.all( - data.content.map(async (block) => { - const blockType = block.type.toString().toLowerCase() - const fields = {} + if (!loading) { + setLoading(true) + async function processBlocks(data) { + try { + const blocks = await Promise.all( + data.content.map(async (block) => { + const blockType = block.type.toString().toLowerCase() + const fields = {} - Object.keys(block.props).forEach((field) => { - const fieldName = `${drupalFieldPrefix}${field}` + Object.keys(block.props).forEach((field) => { + const fieldName = `${drupalFieldPrefix}${field}` - if (field !== "id") { - fields[fieldName] = block.props[field] - } - }) + if (field !== "id") { + fields[fieldName] = block.props[field] + } + }) - return drupal.createResource( - `paragraph--${blockType}`, - { - data: { - attributes: { ...fields }, - }, - }, - { - withAuth: { - clientId: process.env - .NEXT_PUBLIC_DRUPAL_CLIENT_ID as string, - clientSecret: process.env - .NEXT_PUBLIC_DRUPAL_CLIENT_SECRET as string, + return drupal.createResource( + `paragraph--${blockType}`, + { + data: { + attributes: { ...fields }, + }, }, - } - ) - }) - ) + { + withAuth: { + clientId: process.env + .NEXT_PUBLIC_DRUPAL_CLIENT_ID as string, + clientSecret: process.env + .NEXT_PUBLIC_DRUPAL_CLIENT_SECRET as string, + }, + } + ) + }) + ) - return blocks // Returns the fully resolved array of blocks - } catch (error) { - console.error("Error processing blocks:", error) + return blocks // Returns the fully resolved array of blocks + } catch (error) { + console.error("Error processing blocks:", error) + } } - } - const blocks = await processBlocks(data) - const blocksRef: { id: string; type: string }[] = [] - blocks && - blocks.forEach((block) => - blocksRef.push({ - id: block.id, - type: block.type, - meta: { - target_revision_id: block.drupal_internal__revision_id, - }, - }) - ) - const page = await drupal.createResource( - "node--page", - { - data: { - attributes: { - title: data.root?.props?.title || "Default Title", - }, - relationships: { - field_page_builder: { - data: blocksRef, + // Check that we are already POSTing to Drupal + const blocks = await processBlocks(data) + const blocksRef: { id: string; type: string }[] = [] + blocks && + blocks.forEach((block) => + blocksRef.push({ + id: block.id, + type: block.type, + meta: { + target_revision_id: block.drupal_internal__revision_id, + }, + }) + ) + + const page = drupal.createResource( + "node--page", + { + data: { + attributes: { + title: data.root?.props?.title || "Default Title", + }, + relationships: { + field_page_builder: { + data: blocksRef, + }, }, }, }, - }, - { - withAuth: { - clientId: process.env.NEXT_PUBLIC_DRUPAL_CLIENT_ID, - clientSecret: process.env.NEXT_PUBLIC_DRUPAL_CLIENT_SECRET, + { + withAuth: { + clientId: process.env.NEXT_PUBLIC_DRUPAL_CLIENT_ID, + clientSecret: process.env.NEXT_PUBLIC_DRUPAL_CLIENT_SECRET, + }, + } + ) + + const path = (await page)?.path?.alias + ? `${(await page)?.path?.alias}/edit` + : `/node/${(await page).drupal_internal__nid}/edit` + + toast(`Published ${data.root?.props?.title}`, { + action: { + label: "View", + onClick: () => router.push(path), }, - } - ) + }) + + router.push(path) + + return page + } }} /> ) diff --git a/frontend/starters/development/app/edit/[...puckPath]/client.tsx b/frontend/starters/development/app/edit/[...puckPath]/client.tsx index ae68d41..a66bc50 100644 --- a/frontend/starters/development/app/edit/[...puckPath]/client.tsx +++ b/frontend/starters/development/app/edit/[...puckPath]/client.tsx @@ -4,12 +4,13 @@ import { Puck } from "@measured/puck" import config from "../../../puck.config" import { drupal } from "@/lib/drupal" import { drupalFieldPrefix } from "@powerstack/utils" -import toast from "react-hot-toast" -import Link from "next/link" -import { Header } from "@/components/admin/Header/Header" +import { toast } from "sonner" +import { useRouter } from "next/navigation" +import { triggerRevalidation } from "@/lib/trigger-revalidation" +import { useEffect } from "react" export function Client({ path, data }: { path: string; data: Data }) { - const backendUrl = process.env.NEXT_PUBLIC_DRUPAL_HOST + const router = useRouter() return ( ( - <> - Published!   - - View page - - - )) + triggerRevalidation(path) + toast.success(`Published ${data.root?.props?.title}`, { + action: { + label: "View", + onClick: () => router.push(path), + }, + duration: 5000, + }) } catch (error) { console.error("Error processing page:", error) - toast.error("This didn't work.") + toast("Something didn't work.") } }} /> diff --git a/frontend/starters/development/favicon.ico b/frontend/starters/development/app/favicon.ico similarity index 100% rename from frontend/starters/development/favicon.ico rename to frontend/starters/development/app/favicon.ico diff --git a/frontend/starters/development/app/layout.tsx b/frontend/starters/development/app/layout.tsx index 76ec52b..4861ccb 100644 --- a/frontend/starters/development/app/layout.tsx +++ b/frontend/starters/development/app/layout.tsx @@ -1,4 +1,3 @@ -import { DraftAlert } from "@/components/misc/DraftAlert" import type { Metadata } from "next" import type { ReactNode } from "react" import "@mantine/core/styles.css" @@ -6,7 +5,7 @@ import { MantineProvider, ColorSchemeScript } from "@mantine/core" import { theme } from "../theme" import "@/styles/globals.css" -import { Toaster } from "react-hot-toast" +import { Toaster } from "@/components/ui/sonner" import { Header } from "@/components/admin/Header/Header" import { getServerSession } from "next-auth/next" @@ -40,9 +39,8 @@ export default async function RootLayout({ content="minimum-scale=1, initial-scale=1, width=device-width, user-scalable=no" /> - - - + + {session &&
} {children} diff --git a/frontend/starters/development/components/admin/Header/Header.tsx b/frontend/starters/development/components/admin/Header/Header.tsx index 5ecd1cf..84c9086 100644 --- a/frontend/starters/development/components/admin/Header/Header.tsx +++ b/frontend/starters/development/components/admin/Header/Header.tsx @@ -1,7 +1,14 @@ import Link from "next/link" import Image from "next/image" -import { CircleUser, Menu, Package2, Search, Plus } from "lucide-react" +import { + CircleUser, + Menu, + Package2, + Search, + Plus, + ArrowUpRight, +} from "lucide-react" import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" @@ -18,10 +25,10 @@ import smallLogo from "@/images/logo-nav-small.svg" export const Header = () => { return ( -
+
@@ -96,7 +107,7 @@ export const Header = () => { > Content - @@ -110,19 +121,19 @@ export const Header = () => { Settings - + */}
- + {/* + /> */}
@@ -135,10 +146,17 @@ export const Header = () => { My Account - Settings - Support + {/* Settings */} + + Support + - Logout + + Logout +
diff --git a/frontend/starters/development/components/drupal/BasicPage.tsx b/frontend/starters/development/components/drupal/BasicPage.tsx index 00ad0b6..080ab8a 100644 --- a/frontend/starters/development/components/drupal/BasicPage.tsx +++ b/frontend/starters/development/components/drupal/BasicPage.tsx @@ -59,11 +59,16 @@ export async function BasicPage({ node, path }: BasicPageProps) { return ( <> -
+
+
+ +
) } diff --git a/frontend/starters/development/components/ui/dropdown-menu.tsx b/frontend/starters/development/components/ui/dropdown-menu.tsx index f69a0d6..9484b9d 100644 --- a/frontend/starters/development/components/ui/dropdown-menu.tsx +++ b/frontend/starters/development/components/ui/dropdown-menu.tsx @@ -5,6 +5,9 @@ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import { Check, ChevronRight, Circle } from "lucide-react" import { cn } from "@/lib/utils" +import { toast } from "sonner" +import { drupal } from "@/lib/drupal" +import { useRouter } from "next/navigation" const DropdownMenu = DropdownMenuPrimitive.Root @@ -90,6 +93,42 @@ const DropdownMenuItem = React.forwardRef< {...props} /> )) + +const DropdownMenuDeleteItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, node, ...props }, ref) => { + const router = useRouter() + return ( + { + toast.warning(`Are you sure you want to delete ${node.title}`, { + action: { + label: "Delete", + onClick: () => { + drupal.deleteResource(node.type, node.id) + router.refresh() + }, + }, + cancel: { + label: "Cancel", + onClick: () => {}, + }, + }) + }} + {...props} + /> + ) +}) + DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName const DropdownMenuCheckboxItem = React.forwardRef< @@ -186,6 +225,7 @@ export { DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, + DropdownMenuDeleteItem, DropdownMenuCheckboxItem, DropdownMenuRadioItem, DropdownMenuLabel, diff --git a/frontend/starters/development/components/ui/sonner.tsx b/frontend/starters/development/components/ui/sonner.tsx new file mode 100644 index 0000000..452f4d9 --- /dev/null +++ b/frontend/starters/development/components/ui/sonner.tsx @@ -0,0 +1,31 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner } from "sonner" + +type ToasterProps = React.ComponentProps + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster } diff --git a/frontend/starters/development/lib/trigger-revalidation.ts b/frontend/starters/development/lib/trigger-revalidation.ts new file mode 100644 index 0000000..4a1ebf8 --- /dev/null +++ b/frontend/starters/development/lib/trigger-revalidation.ts @@ -0,0 +1,35 @@ +"use server" + +export const triggerRevalidation = async (path) => { + const secret = process.env.NEXT_REVALIDATE_SECRET + const baseUrl = process.env.NEXT_HOST // This should ideally be kept secure + const revalidateUrl = `${baseUrl}/api/revalidate?path=${path}&secret=${secret}` + + try { + const response = await fetch(revalidateUrl, { + method: "GET", + }) + const result = await response + if (result.ok) { + console.log("Page revalidated successfully") + } else { + console.error("Failed to revalidate") + } + } catch (error) { + console.error("Error triggering revalidation:", error) + } + const revalidateEditUrl = `${baseUrl}/api/revalidate?path=/edit/[...puckPath]/page&secret=${secret}` + try { + const response = await fetch(revalidateEditUrl, { + method: "GET", + }) + const result = await response + if (result.ok) { + console.log("Page revalidated successfully") + } else { + console.error("Failed to revalidate") + } + } catch (error) { + console.error("Error triggering revalidation:", error) + } +} diff --git a/frontend/starters/development/middleware.ts b/frontend/starters/development/middleware.ts index 1aa2df0..f8ce5a0 100644 --- a/frontend/starters/development/middleware.ts +++ b/frontend/starters/development/middleware.ts @@ -8,6 +8,10 @@ export async function middleware(req: NextRequest) { const { cookies } = req const sessionToken = cookies.get("next-auth.session-token") + if (sessionToken) { + res.headers.set("x-middleware-cache", "no-cache") + } + if (req.method === "GET") { // Rewrite routes that match "/[...puckPath]/edit" to "/puck/[...puckPath]" if (req.nextUrl.pathname.endsWith("/edit")) { @@ -20,7 +24,12 @@ export async function middleware(req: NextRequest) { if (!sessionToken) { return NextResponse.redirect(new URL("/api/auth/signin", req.url)) } - return NextResponse.rewrite(new URL(pathWithEditPrefix, req.url)) + const response = NextResponse.rewrite( + new URL(pathWithEditPrefix, req.url) + ) + response.headers.set("x-middleware-cache", "no-cache") + + return response } if (req.nextUrl.pathname === "/") { diff --git a/frontend/starters/development/package.json b/frontend/starters/development/package.json index 9e45f34..347ed58 100644 --- a/frontend/starters/development/package.json +++ b/frontend/starters/development/package.json @@ -31,10 +31,12 @@ "next": "^14", "next-auth": "^4.24.7", "next-drupal": "workspace:^", + "next-themes": "^0.3.0", "postcss-preset-mantine": "^1.13.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hot-toast": "^2.4.1", + "sonner": "^1.4.41", "tailwind": "^4.0.0", "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7" diff --git a/frontend/starters/development/styles/globals.css b/frontend/starters/development/styles/globals.css index 6d8a3a3..0a64a83 100644 --- a/frontend/starters/development/styles/globals.css +++ b/frontend/starters/development/styles/globals.css @@ -75,6 +75,30 @@ } } +body.logged-in { + margin-top: 64px; +} + +body:not(.logged-in) #signin-button { + position: fixed; + bottom: 20px; + right: 20px; +} + +body.logged-in #signin-button { + display: none; +} + +body.logged-in #edit-button { + position: fixed; + bottom: 20px; + right: 20px; +} + +body:not(.logged-in) #edit-button { + display: none; +} + .Puck > div { top: 66px; } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 1af27e2..30a060f 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -4773,12 +4773,14 @@ __metadata: next: ^14 next-auth: ^4.24.7 next-drupal: "workspace:^" + next-themes: ^0.3.0 postcss: ^8.4.31 postcss-preset-mantine: ^1.13.0 prettier: ^3.1.0 react: ^18.2.0 react-dom: ^18.2.0 react-hot-toast: ^2.4.1 + sonner: ^1.4.41 tailwind: ^4.0.0 tailwind-merge: ^2.3.0 tailwindcss: ^3.4.3 @@ -8074,6 +8076,16 @@ __metadata: languageName: unknown linkType: soft +"next-themes@npm:^0.3.0": + version: 0.3.0 + resolution: "next-themes@npm:0.3.0" + peerDependencies: + react: ^16.8 || ^17 || ^18 + react-dom: ^16.8 || ^17 || ^18 + checksum: 4285c4969eac517ad7addd773bcb71e7d14bc6c6e3b24eb97b80a6e06ac03fb6cb345e75dfb448156d14430d06289948eb8cfdeb52402ca7ce786093d01d2878 + languageName: node + linkType: hard + "next@npm:^13.4 || ^14, next@npm:^14": version: 14.2.2 resolution: "next@npm:14.2.2" @@ -9648,6 +9660,16 @@ __metadata: languageName: node linkType: hard +"sonner@npm:^1.4.41": + version: 1.4.41 + resolution: "sonner@npm:1.4.41" + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + checksum: 312055fdd88fc2bb738462c5a2f03b268e23162b6fdc91eb4dc4e5cac9bad926087fe1f488a8b82bc2a9ff92f14843aa5a579a4923917f3db2e82b47d4073cb6 + languageName: node + linkType: hard + "source-map-js@npm:^1.0.2, source-map-js@npm:^1.2.0": version: 1.2.0 resolution: "source-map-js@npm:1.2.0"