Skip to main content

dioxus_mdx/components/openapi/
endpoint_page.rs

1//! Two-column Mintlify-style endpoint page component.
2
3use dioxus::prelude::*;
4#[cfg(feature = "highlight")]
5use dioxus_code::{Code, CodeTheme, Language, SourceCode, Theme};
6
7use crate::parser::{ApiOperation, OpenApiSpec};
8
9use super::method_badge::MethodBadge;
10use super::parameters_list::ParametersList;
11use super::request_body::RequestBodySection;
12use super::responses_list::ResponsesList;
13
14/// Language selector for the endpoint page's code samples, kept independent of
15/// `dioxus-code` so the markup compiles with the `highlight` feature disabled.
16enum SampleLang {
17    Bash,
18    Json,
19}
20
21/// Render a syntax-highlighted code sample using the fixed GitHub Light / Tokyo Night
22/// system theme (endpoint pages don't follow the site theme toggle).
23#[cfg(feature = "highlight")]
24fn code_sample(code: String, lang: SampleLang) -> Element {
25    // `from_slug` returns `None` when the grammar's `lang-*` feature is off, so
26    // the sample degrades to plain text instead of failing to compile.
27    let slug = match lang {
28        SampleLang::Bash => "bash",
29        SampleLang::Json => "json",
30    };
31    let Some(language) = Language::from_slug(slug) else {
32        return crate::components::code::plain_code_block(&code);
33    };
34    let theme = CodeTheme::system(Theme::GITHUB_LIGHT, Theme::TOKYO_NIGHT);
35    rsx! {
36        Code {
37            src: SourceCode::new(language, code),
38            theme,
39        }
40    }
41}
42
43/// Fallback code sample when the `highlight` feature is disabled: escaped plain text
44/// in the same `<pre class="dxc">` markup, without token coloring.
45#[cfg(not(feature = "highlight"))]
46fn code_sample(code: String, _lang: SampleLang) -> Element {
47    crate::components::code::plain_code_block(&code)
48}
49
50/// Props for EndpointPage component.
51#[derive(Props, Clone, PartialEq)]
52pub struct EndpointPageProps {
53    /// The operation to display.
54    pub operation: ApiOperation,
55    /// The full OpenAPI spec (for base URL).
56    pub spec: OpenApiSpec,
57}
58
59/// Full-page two-column layout for a single API endpoint.
60///
61/// Left column: method badge, path, summary, description, parameters, request body, responses.
62/// Right column (sticky): curl example, response JSON example.
63#[component]
64pub fn EndpointPage(props: EndpointPageProps) -> Element {
65    let op = &props.operation;
66    let spec = &props.spec;
67
68    let base_url = spec
69        .servers
70        .first()
71        .map(|s| s.url.as_str())
72        .unwrap_or("https://api.example.com");
73
74    let curl = op.generate_curl(base_url);
75    let response_example = op.generate_response_example();
76
77    let method_bg = op.method.bg_class();
78
79    rsx! {
80        div { class: "flex flex-col lg:flex-row gap-0",
81            // Left column — scrollable content
82            div { class: "flex-1 min-w-0 px-8 py-12 lg:px-12",
83                div { class: "max-w-2xl",
84                    // Method + Path header
85                    div { class: "flex items-center gap-3 mb-6",
86                        span {
87                            class: "px-3 py-1.5 rounded-lg font-mono text-sm font-bold border {method_bg}",
88                            "{op.method.as_str()}"
89                        }
90                        code { class: "font-mono text-lg text-base-content",
91                            "{op.path}"
92                        }
93                        if op.deprecated {
94                            span { class: "badge badge-warning badge-sm", "deprecated" }
95                        }
96                    }
97
98                    // Summary as heading
99                    if let Some(summary) = &op.summary {
100                        h1 { class: "text-3xl font-bold tracking-tight mb-3",
101                            "{summary}"
102                        }
103                    }
104
105                    // Description
106                    if let Some(desc) = &op.description {
107                        p { class: "text-base text-base-content/70 mb-6 leading-relaxed",
108                            "{desc}"
109                        }
110                    }
111
112                    // Base URL
113                    div { class: "mb-8 flex items-center gap-2",
114                        span { class: "text-xs text-base-content/50 font-semibold uppercase tracking-wider",
115                            "Base URL"
116                        }
117                        code { class: "text-sm font-mono text-base-content/70 bg-base-200 px-2 py-1 rounded",
118                            "{base_url}"
119                        }
120                    }
121
122                    // Parameters section
123                    if !op.parameters.is_empty() {
124                        div { class: "mb-8",
125                            h2 { class: "text-lg font-semibold mb-4 pb-2 border-b border-base-300",
126                                "Parameters"
127                            }
128                            ParametersList { parameters: op.parameters.clone() }
129                        }
130                    }
131
132                    // Request Body section
133                    if let Some(body) = &op.request_body {
134                        div { class: "mb-8",
135                            h2 { class: "text-lg font-semibold mb-4 pb-2 border-b border-base-300",
136                                "Request Body"
137                            }
138                            RequestBodySection { body: body.clone() }
139                        }
140                    }
141
142                    // Responses section
143                    if !op.responses.is_empty() {
144                        div { class: "mb-8",
145                            h2 { class: "text-lg font-semibold mb-4 pb-2 border-b border-base-300",
146                                "Responses"
147                            }
148                            ResponsesList { responses: op.responses.clone() }
149                        }
150                    }
151                }
152            }
153
154            // Right column — sticky code examples
155            aside { class: "lg:w-[45%] lg:shrink-0 lg:border-l border-base-300 bg-base-200/20",
156                div { class: "lg:sticky lg:top-16 lg:h-[calc(100vh-4rem)] lg:overflow-y-auto p-6 space-y-6",
157                    // Request example
158                    div {
159                        h3 { class: "text-sm font-semibold text-base-content/70 uppercase tracking-wider mb-3",
160                            "Request"
161                        }
162                        div { class: "rounded-lg border border-base-300 overflow-hidden",
163                            div { class: "px-3 py-2 bg-base-300/50 border-b border-base-300 flex items-center gap-2",
164                                MethodBadge { method: op.method }
165                                code { class: "text-xs font-mono text-base-content/70 truncate",
166                                    "{op.path}"
167                                }
168                            }
169                            div { class: "dk-code-block-body bg-base-200",
170                                {code_sample(curl.clone(), SampleLang::Bash)}
171                            }
172                        }
173                    }
174
175                    // Response example
176                    if let Some((status_code, response_json)) = &response_example {
177                        {
178                            let status_color = if status_code.starts_with('2') {
179                                "badge-success"
180                            } else if status_code.starts_with('3') {
181                                "badge-info"
182                            } else {
183                                "badge-ghost"
184                            };
185                            rsx! {
186                                div {
187                                    h3 { class: "text-sm font-semibold text-base-content/70 uppercase tracking-wider mb-3",
188                                        "Response"
189                                    }
190                                    div { class: "rounded-lg border border-base-300 overflow-hidden",
191                                        div { class: "px-3 py-2 bg-base-300/50 border-b border-base-300 flex items-center gap-2",
192                                            span { class: "badge {status_color} badge-sm font-mono font-bold",
193                                                "{status_code}"
194                                            }
195                                            span { class: "text-xs text-base-content/50",
196                                                "application/json"
197                                            }
198                                        }
199                                        div { class: "dk-code-block-body bg-base-200 max-h-[60vh] overflow-y-auto",
200                                            {code_sample(response_json.clone(), SampleLang::Json)}
201                                        }
202                                    }
203                                }
204                            }
205                        }
206                    }
207                }
208            }
209        }
210    }
211}