diff --git a/src/app/(landing)/[locale]/actions/[slug]/page.tsx b/src/app/(landing)/[locale]/actions/[slug]/page.tsx new file mode 100644 index 0000000..f122ab9 --- /dev/null +++ b/src/app/(landing)/[locale]/actions/[slug]/page.tsx @@ -0,0 +1,151 @@ +import { ActionTriggerView } from "@/components/ActionTriggerView" +import { ActionCard } from "@/components/cards/ActionCard" +import { Aurora } from "@/components/ui/Aurora" +import { HapticButtonLink } from "@/components/ui/HapticButtonLink" +import { LandingContainer } from "@/components/ui/LandingContainer" +import { LinkButton } from "@/components/ui/LinkButton" +import { getActionBySlug, getActionSlugs, getLandingPage, type ActionsLayoutBlock } from "@/lib/cms" +import { SUPPORTED_LOCALES, isSupportedLocale } from "@/lib/i18n" +import { createMetadata } from "@/lib/siteConfig" +import type { Media } from "@/payload-types" +import { IconArrowLeft, IconExternalLink } from "@tabler/icons-react" +import type { Metadata } from "next" +import Image from "next/image" +import { notFound } from "next/navigation" + +export default async function ActionDetailPage({ params }: { params: Promise<{ locale: string, slug: string }> }) { + const { locale, slug } = await params + if (!isSupportedLocale(locale)) notFound() + if (!slug?.trim()) notFound() + + const action = await getActionBySlug(slug, locale) + if (!action) notFound() + const actionsPage = await getLandingPage("actions", locale) + + const icon = action.icon as Media | undefined + const triggers = action.trigger as Media | undefined + const functionDefs = action.functiondefinitions as Media | undefined + const references = (action.references ?? []).filter((reference): reference is Exclude => typeof reference !== "number") + const tags = (action.tags ?? []).filter((tag): tag is string => Boolean(tag)) + const actionsBlock = actionsPage?.layout?.find((block): block is ActionsLayoutBlock => block.blockType === "actions") ?? null + const referencesLabel = actionsBlock?.referencesLabel ?? (locale === "de" ? "Referenzen" : "References") + + return ( + <> + + +
+ + + {locale === "de" ? "Zurück" : "Back"} + + +
+ +
+
+ +
+ {icon?.url && ( +
+ {icon.alt +
+ )} + +
+

{action.title}

+ {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+
+ + {action.documentation?.label && action.documentation?.url && ( + + + {action.documentation.label} + + )} + +
+ {action.description && ( +
+ {action.description} +
+ )} + + + + {references.length > 0 && ( +
+

+ {referencesLabel} +

+
+ {references.map((reference) => ( + + ))} +
+
+ )} +
+
+
+
+ + ) +} + +export async function generateMetadata({ params }: { params: Promise<{ locale: string, slug: string }> }): Promise { + const { locale, slug } = await params + if (!isSupportedLocale(locale)) return createMetadata() + if (!slug?.trim()) return createMetadata() + + const action = await getActionBySlug(slug, locale) + if (!action) return createMetadata() + + return createMetadata({ + title: action.title, + description: action.shortDescription ?? action.description ?? undefined, + }) +} + +export async function generateStaticParams() { + const all = await Promise.all( + SUPPORTED_LOCALES.map(async (locale) => { + const slugs = await getActionSlugs(locale) + return slugs.map((slug) => ({ locale, slug })) + }) + ) + + return all.flat() +} + +export const dynamicParams = false diff --git a/src/app/(landing)/[locale]/actions/page.tsx b/src/app/(landing)/[locale]/actions/page.tsx new file mode 100644 index 0000000..f363ee2 --- /dev/null +++ b/src/app/(landing)/[locale]/actions/page.tsx @@ -0,0 +1,33 @@ +import { ActionsPageClient } from "@/components/ActionsPageClient" +import { Aurora } from "@/components/ui/Aurora" +import { LandingContainer } from "@/components/ui/LandingContainer" +import { getActions, getLandingPage, type ActionsLayoutBlock } from "@/lib/cms" +import { isSupportedLocale } from "@/lib/i18n" +import { getLandingPageMetadata } from "@/lib/pageMetadata" +import { Metadata } from "next" +import { notFound } from "next/navigation" + +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise { + const { locale } = await params + return getLandingPageMetadata("actions", locale) +} + +export default async function ActionsPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params + if (!isSupportedLocale(locale)) notFound() + + const [actions, actionsPage] = await Promise.all([ + getActions(locale), + getLandingPage("actions", locale), + ]) + const actionsBlock = actionsPage?.layout?.find((block): block is ActionsLayoutBlock => block.blockType === "actions") ?? null + + return ( + <> + + + + + + ) +} diff --git a/src/app/(landing)/[locale]/blog/[...slug]/loading.tsx b/src/app/(landing)/[locale]/blog/[slug]/loading.tsx similarity index 100% rename from src/app/(landing)/[locale]/blog/[...slug]/loading.tsx rename to src/app/(landing)/[locale]/blog/[slug]/loading.tsx diff --git a/src/app/(landing)/[locale]/blog/[...slug]/page.tsx b/src/app/(landing)/[locale]/blog/[slug]/page.tsx similarity index 90% rename from src/app/(landing)/[locale]/blog/[...slug]/page.tsx rename to src/app/(landing)/[locale]/blog/[slug]/page.tsx index bfd83f7..1f20fe0 100644 --- a/src/app/(landing)/[locale]/blog/[...slug]/page.tsx +++ b/src/app/(landing)/[locale]/blog/[slug]/page.tsx @@ -21,12 +21,10 @@ function getMediaUrl(value?: number | Media | null) { return new URL(value.url, resolveSiteUrl()).toString() } -export default async function Page({ params }: { params: Promise<{ locale: string, slug: string[] }> }) { +export default async function Page({ params }: { params: Promise<{ locale: string, slug: string }> }) { const { locale, slug } = await params if (!isSupportedLocale(locale)) notFound() - - const normalizedSlug = slug?.join("/")?.trim() - if (!normalizedSlug) notFound() + if (!slug?.trim()) notFound() const page = await getLandingPage("main", locale) const layout = page?.layout ?? [] @@ -38,7 +36,7 @@ export default async function Page({ params }: { params: Promise<{ locale: strin
}> - +
+ + ) +} diff --git a/src/components/ActionTriggerView.tsx b/src/components/ActionTriggerView.tsx new file mode 100644 index 0000000..55d7a71 --- /dev/null +++ b/src/components/ActionTriggerView.tsx @@ -0,0 +1,103 @@ +"use client" + +import { useEffect, useState } from "react" +import { SegmentedControl, SegmentedControlItem, Text } from "@code0-tech/pictor" +import type { Media } from "@/payload-types" +import { ActionTriggerCard } from "@/components/ActionTriggerCard" +import { + extractFunctionDefsFromJson, + extractTriggersFromJson, + fetchMediaJson, + type ExtractedActionTriggerItem, + type ExtractedFunctionDef, + type ExtractedTrigger, +} from "@/lib/actionTriggerExtraction" + +interface ActionTriggerViewProps { + locale: string + triggers: Media | undefined + functionDefs: Media | undefined +} + +const controlClassName = "h-10! w-max! ring-2! ring-white/6! shadow-md!" +const itemClassName = "text-white/70! transition-colors! data-[state=on]:bg-brand/20! data-[state=on]:text-brand!" + +interface DisplayItem { + type: "trigger" | "functionDef" + item: ExtractedActionTriggerItem +} + +export function ActionTriggerView({ locale, triggers, functionDefs }: ActionTriggerViewProps) { + const [viewMode, setViewMode] = useState<"both" | "triggers" | "functionDefs">("both") + const [extractedTriggers, setExtractedTriggers] = useState([]) + const [extractedFunctionDefs, setExtractedFunctionDefs] = useState([]) + + useEffect(() => { + let cancelled = false + + async function loadMediaJson() { + const [triggerJson, functionDefJson] = await Promise.all([ + fetchMediaJson(triggers), + fetchMediaJson(functionDefs), + ]) + + if (cancelled) { + return + } + + setExtractedTriggers(extractTriggersFromJson(triggerJson)) + setExtractedFunctionDefs(extractFunctionDefsFromJson(functionDefJson)) + } + + loadMediaJson().catch(() => { + if (!cancelled) { + setExtractedTriggers([]) + setExtractedFunctionDefs([]) + } + }) + + return () => { + cancelled = true + } + }, [triggers, functionDefs]) + + const visibleItems: DisplayItem[] = [ + ...(viewMode === "both" || viewMode === "triggers" + ? extractedTriggers.map((trigger) => ({ type: "trigger" as const, item: trigger })) + : []), + ...(viewMode === "both" || viewMode === "functionDefs" + ? extractedFunctionDefs.map((functionDef) => ({ type: "functionDef" as const, item: functionDef })) + : []), + ] + + return ( +
+ { + if (value === "both" || value === "triggers" || value === "functionDefs") { + setViewMode(value) + } + }} + className={controlClassName} + > + + {locale === "en" ? "Both" : "Beide"} + + + Triggers + + + FunctionDefinitions + + + +
+ {visibleItems.map(({ type, item }) => ( + + ))} +
+
+ ) +} diff --git a/src/components/ActionsPageClient.tsx b/src/components/ActionsPageClient.tsx new file mode 100644 index 0000000..d603bca --- /dev/null +++ b/src/components/ActionsPageClient.tsx @@ -0,0 +1,79 @@ +"use client" + +import { ActionCard } from "@/components/cards/ActionCard" +import type { ActionItem } from "@/lib/cms" +import { TextInput } from "@code0-tech/pictor" +import { IconSearch } from "@tabler/icons-react" +import type { ChangeEvent } from "react" +import { useMemo, useState } from "react" + +interface ActionsPageContent { + heading: string + description: string + searchPlaceholder: string + noActionsFoundLabel: string + referencesLabel: string +} + +interface ActionsPageClientProps { + actions: ActionItem[] + locale: string + content?: Partial | null +} + +const defaultContent: ActionsPageContent = { + heading: "Actions", + description: "Browse available actions and integrations.", + searchPlaceholder: "Search actions", + noActionsFoundLabel: "No actions found for your search.", + referencesLabel: "References", +} + +export function ActionsPageClient({ actions, locale, content }: ActionsPageClientProps) { + const labels = { ...defaultContent, ...content } + const [search, setSearch] = useState("") + + const filteredActions = useMemo(() => { + const searchTerm = search.trim().toLowerCase() + if (!searchTerm) return actions + + return actions.filter((action) => { + const item = [ + action.title, + action.shortDescription, + action.description, + ...(action.tags ?? []), + ] + .filter((value) => Boolean(value)) + .join(" ") + .toLowerCase() + + return item.includes(searchTerm) + }) + }, [actions, search]) + + return ( +
+
+

+ {labels.heading} +

+

{labels.description}

+
+ ) => setSearch(e.currentTarget.value)} + placeholder={labels.searchPlaceholder} + left={} + clearable + className="text-white!" + /> +
+ {filteredActions.map((action) => )} +
+ {filteredActions.length === 0 ? ( +

{labels.noActionsFoundLabel}

+ ) : null} +
+ ) +} diff --git a/src/components/JobsPageClient.tsx b/src/components/JobsPageClient.tsx index 91991bc..654c1fb 100644 --- a/src/components/JobsPageClient.tsx +++ b/src/components/JobsPageClient.tsx @@ -11,6 +11,7 @@ import { TextInput } from "@code0-tech/pictor" import { IconChevronDown, IconSearch } from "@tabler/icons-react" +import type { ChangeEvent } from "react" import { useMemo, useState } from "react" interface JobsPageContent { @@ -81,11 +82,11 @@ export function JobsPageClient({ jobs, locale, content }: JobsPageClientProps) {
setSearch(event.target.value)} + onChange={(e: ChangeEvent) => setSearch(e.currentTarget.value)} placeholder={labels.searchPlaceholder} - left={[]} + left={} clearable - className="w-full rounded-xl bg-white/10 border border-white/15 text-white/85" + className="text-white!" />
diff --git a/src/components/cards/ActionCard.tsx b/src/components/cards/ActionCard.tsx new file mode 100644 index 0000000..2c8f80b --- /dev/null +++ b/src/components/cards/ActionCard.tsx @@ -0,0 +1,46 @@ +"use client" + +import type { ActionItem } from "@/lib/cms" +import type { AppLocale } from "@/lib/i18n" +import type { Media } from "@/payload-types" +import { Card } from "@code0-tech/pictor" +import Image from "next/image" +import Link from "next/link" +import { useWebHaptics } from "web-haptics/react" + +export function ActionCard({ action, locale }: { action: ActionItem, locale: AppLocale | string }) { + const { trigger } = useWebHaptics() + const icon = action.icon as Media | undefined + + return ( + trigger("medium")} className="block"> + +