1use chrono::{DateTime, Utc};
4use leptos::ev::{MouseEvent, SubmitEvent};
5use leptos::prelude::*;
6
7use super::AlertBoxDemo;
8use crate::components::admin_palette::{AdminPaletteView, Item, PostSearch};
9use crate::components::forms::{
10 FormInput, FormLabel, FormSelect, FormTextarea, NumberInput, TimePicker, ToggleSwitch,
11};
12use crate::components::palette::{contains_ci, use_search_palette_provider};
13use crate::components::ui::{
14 use_sheet_close, use_toast, BottomSheet, Breadcrumb, Button, ButtonSize, ButtonVariant,
15 Checkbox, Composer, ComposerInput, ComposerSend, ComposerSize, ConfirmDialog, Crumb, Icon,
16 IconButton, IconName, ImagePlaceholder, LoadError, Menu, MenuItem, ModalShell, Pagination,
17 Popover, Radio, SegmentedControl, SegmentedItem, SegmentedKind, SegmentedSize, SideSheet,
18 StatusBadge, StatusDot, TablePager, TagChip, ToastTone, Tooltip, UserAvatar,
19};
20use crate::models::post::{Post, PostListItem, PostStatus};
21use crate::routes::Route;
22
23fn segmented_mode_items() -> Vec<SegmentedItem<String>> {
24 vec![
25 SegmentedItem::new("light".to_string(), "浅色").icon(IconName::Sun),
26 SegmentedItem::new("dark".to_string(), "深色").icon(IconName::Moon),
27 SegmentedItem::new("system".to_string(), "跟随系统").icon(IconName::Monitor),
28 ]
29}
30
31#[component]
32pub(super) fn LiveComponentPreview(
33 name: String,
34 detail: bool,
35 #[prop(optional)] checked: Option<RwSignal<bool>>,
36 #[prop(optional)] danger: Option<RwSignal<bool>>,
37 #[prop(optional)] alert_variant: Option<RwSignal<String>>,
38 #[prop(optional)] alert_message: Option<RwSignal<String>>,
39) -> impl IntoView {
40 let checked = checked.unwrap_or_else(|| RwSignal::new(true));
41 let danger = danger.unwrap_or_else(|| RwSignal::new(false));
42 let alert_variant = alert_variant.unwrap_or_else(|| RwSignal::new("success".to_string()));
43 let alert_message =
44 alert_message.unwrap_or_else(|| RwSignal::new("已保存,新的想法正在生长。".to_string()));
45 let switch_on = RwSignal::new(true);
46 let selected_radio = RwSignal::new("public".to_string());
47 let select_value = RwSignal::new("leaf".to_string());
48 let time_value = RwSignal::new("08:30".to_string());
49 let input_value = RwSignal::new(String::new());
50 let textarea_value = RwSignal::new(String::new());
51 let tab_value = RwSignal::new("all".to_string());
52 let seg_mode = RwSignal::new("light".to_string());
53 let page = RwSignal::new(1i32);
54 let number_value = RwSignal::new(14i64);
55 let clicked = RwSignal::new(false);
56 let popover_open = RwSignal::new(false);
57 let popover_x = RwSignal::new(550i32);
58 let popover_y = RwSignal::new(330i32);
59 let modal_open = RwSignal::new(false);
60 let modal_closing = RwSignal::new(false);
61 let confirm_open = RwSignal::new(false);
62 let filter = RwSignal::new(false);
63
64 match name.as_str() {
65 "Checkbox" => view! {
66 <div class="showcase-preview-stack">
67 <label class=move || {
68 if danger.get() { "showcase-check-row danger" } else { "showcase-check-row" }
69 }>
70 <Checkbox
71 checked=checked
72 danger=danger
73 onchange=move |value: bool| checked.set(value)
74 />
75 "记住这个小小的选择"
76 </label>
77 {(!detail)
78 .then(|| {
79 view! {
80 <label class="showcase-check-row">
81 <Checkbox
82 checked=filter
83 onchange=move |value: bool| filter.set(value)
84 />
85 "接收新的生长消息"
86 </label>
87 <label class="showcase-check-row danger">
88 <Checkbox checked=true danger=true onchange=move |_: bool| {}/>
89 "确认移除这条记录"
90 </label>
91 }
92 })}
93 </div>
94 }
95 .into_any(),
96 "Radio" => view! {
97 <div class="showcase-preview-stack" role="radiogroup" aria-label="分享范围">
98 {[("public", "公开分享"), ("private", "仅自己可见")]
99 .into_iter()
100 .map(|(value, label)| {
101 view! {
102 <label class="showcase-check-row">
103 <Radio
104 name="showcase-visibility"
105 value=value
106 checked=move || selected_radio.get() == value
107 onchange=move |value: String| selected_radio.set(value)
108 />
109 {label}
110 </label>
111 }
112 })
113 .collect_view()}
114 <label class="showcase-check-row">
115 <Radio
116 name="showcase-visibility"
117 value="unavailable"
118 checked=false
119 disabled=true
120 onchange=move |_: String| {}
121 />
122 "暂不可用"
123 </label>
124 </div>
125 }
126 .into_any(),
127 "ToggleSwitch" => view! {
128 <div class="showcase-preview-stack">
129 <div class="showcase-switch-row">
130 <span>"接收更新"</span>
131 <ToggleSwitch
132 checked=switch_on
133 ontoggle=move |()| switch_on.update(|on| *on = !*on)
134 />
135 </div>
136 <div class="showcase-switch-row">
137 <span>"自动备份"</span>
138 <ToggleSwitch
139 checked=filter
140 ontoggle=move |()| filter.update(|on| *on = !*on)
141 />
142 </div>
143 </div>
144 }
145 .into_any(),
146 "FormSelect" => view! {
147 <div class="showcase-preview-stack">
148 <div class="showcase-preview-field">
149 <FormLabel label="素材排序" html_for="showcase-sort"/>
150 <FormSelect
151 id="showcase-sort"
152 value=select_value
153 options=vec![
154 ("leaf".to_string(), "最新上传"),
155 ("design".to_string(), "体积最大"),
156 ("daily".to_string(), "引用最多"),
157 ("name".to_string(), "按文件名"),
158 ]
159 disabled_options=vec![("name".to_string(), "需要先重建索引")]
160 onchange=move |value: String| select_value.set(value)
161 />
162 </div>
163 <div class="showcase-preview-field">
164 <FormLabel label="主题标签(多于 8 项时可筛选)" html_for="showcase-tag"/>
165 <FormSelect
166 id="showcase-tag"
167 value=tab_value
168 options=vec![
169 ("all".to_string(), "全部标签"),
170 ("rust".to_string(), "Rust"),
171 ("dioxus".to_string(), "Dioxus"),
172 ("pg".to_string(), "PostgreSQL"),
173 ("wasm".to_string(), "WebAssembly"),
174 ("design".to_string(), "设计"),
175 ("arch".to_string(), "架构"),
176 ("note".to_string(), "读书笔记"),
177 ("emu".to_string(), "模拟器"),
178 ("mcp".to_string(), "MCP"),
179 ]
180 onchange=move |value: String| tab_value.set(value)
181 />
182 </div>
183 </div>
184 }
185 .into_any(),
186 "TimePicker" => view! {
187 <TimePicker
188 value=time_value
189 onchange=move |value: String| time_value.set(value)
190 />
191 }
192 .into_any(),
193 "FormInput" => view! {
194 <FormInput
195 r#type="text"
196 placeholder="一个新的开始…"
197 value=input_value
198 oninput=move |value: String| input_value.set(value)
199 />
200 }
201 .into_any(),
202 "FormTextarea" => view! {
203 <FormTextarea
204 rows=4
205 placeholder="写下这篇文章想说的事…"
206 value=textarea_value
207 oninput=move |value: String| textarea_value.set(value)
208 />
209 }
210 .into_any(),
211 "FormLabel" => view! {
212 <div class="showcase-preview-field">
213 <FormLabel label="文章标题" html_for="showcase-title"/>
214 <FormInput
215 id="showcase-title"
216 r#type="text"
217 placeholder="点击标签也能聚焦这里"
218 value=input_value
219 oninput=move |value: String| input_value.set(value)
220 />
221 </div>
222 }
223 .into_any(),
224 "Button" => view! {
225 <div class="flex flex-col gap-4">
226 <div class="showcase-preview-row flex-wrap">
227 <Button onclick=move |_: MouseEvent| clicked.set(true)>
228 {move || if clicked.get() { "已发布" } else { "发布文章" }}
229 </Button>
230 <Button variant=ButtonVariant::Secondary icon=IconName::Save>
231 "保存草稿"
232 </Button>
233 <Button variant=ButtonVariant::Outline>"预览"</Button>
234 <Button variant=ButtonVariant::Ghost>"取消"</Button>
235 <Button variant=ButtonVariant::Danger>"永久删除"</Button>
236 <Button variant=ButtonVariant::Secondary loading=true>
237 "处理中"
238 </Button>
239 </div>
240 <div class="showcase-preview-row flex-wrap items-center">
241 <Button size=ButtonSize::Xs variant=ButtonVariant::Secondary>
242 "XS"
243 </Button>
244 <Button size=ButtonSize::Sm variant=ButtonVariant::Secondary>
245 "SM"
246 </Button>
247 <Button variant=ButtonVariant::Secondary>"MD"</Button>
248 <Button size=ButtonSize::Lg variant=ButtonVariant::Secondary>
249 "LG"
250 </Button>
251 <Button variant=ButtonVariant::DangerSoft icon=IconName::Trash>
252 "清空回收站"
253 </Button>
254 </div>
255 </div>
256 }
257 .into_any(),
258 "IconButton" => view! {
259 <div class="showcase-preview-row">
260 <IconButton icon=IconName::Copy label="复制链接"/>
261 <IconButton
262 icon=IconName::Search
263 label="搜索"
264 shortcut="⌘K"
265 variant=ButtonVariant::Secondary
266 />
267 <IconButton
268 icon=IconName::PanelLeft
269 label="收起侧栏"
270 pressed=Signal::derive(move || Some(clicked.get()))
271 onclick=move |_: MouseEvent| clicked.update(|on| *on = !*on)
272 />
273 <IconButton icon=IconName::Trash label="删除" disabled=true/>
274 </div>
275 }
276 .into_any(),
277 "NumberInput" => view! {
278 <div class="showcase-preview-field">
279 <FormLabel label="日志保留" html_for="showcase-retention"/>
280 <NumberInput
281 id="showcase-retention"
282 value=number_value
283 min=1
284 max=90
285 unit="天"
286 hint="超过保留期的日志由后台任务自动清理。".to_string()
287 onchange=move |value: i64| number_value.set(value)
288 />
289 </div>
290 }
291 .into_any(),
292 "Menu" => view! {
293 <div class="flex flex-col items-center gap-3">
294 <Menu
295 label="更多操作"
296 items=vec![
297 MenuItem::new("edit", "编辑").icon(IconName::Pencil),
298 MenuItem::new("copy", "复制链接").icon(IconName::Link).shortcut("⌘C"),
299 MenuItem::new("archive", "归档")
300 .icon(IconName::Archive)
301 .disabled("已发布的文章不能归档"),
302 MenuItem::new("trash", "移入回收站")
303 .icon(IconName::Trash)
304 .danger()
305 .separated(),
306 ]
307 on_select=move |id: &'static str| input_value.set(id.to_string())
308 />
309 {move || {
310 (!input_value.get().is_empty())
311 .then(|| {
312 view! {
313 <p class="text-xs text-fg-3 font-mono">
314 {move || format!("on_select: {}", input_value.get())}
315 </p>
316 }
317 })
318 }}
319 </div>
320 }
321 .into_any(),
322 "ToastRegion" => view! { <ToastDemo/> }.into_any(),
323 "BottomSheet" => view! { <BottomSheetDemo/> }.into_any(),
324 "Breadcrumb" => view! {
325 <Breadcrumb items=vec![
326 Crumb::link("关于", Route::About),
327 Crumb::link("组件图鉴", Route::ComponentShowcase),
328 Crumb::current("复选框"),
329 ]/>
330 }
331 .into_any(),
332 "SearchPalette" => view! { <SearchPaletteDemo/> }.into_any(),
333 "SideSheet" => view! { <SideSheetDemo/> }.into_any(),
334 "Composer" => view! { <ComposerDemo/> }.into_any(),
335 "ComposerInput" => view! { <ComposerInputDemo/> }.into_any(),
336 "ComposerSend" => view! { <ComposerSendDemo/> }.into_any(),
337 "AdminPalette" => view! { <AdminPaletteDemo/> }.into_any(),
338 "SegmentedControl" => view! {
339 <div class="showcase-preview-stack w-full">
340 <SegmentedControl
341 items=vec![
342 SegmentedItem::new("all".to_string(), "全部"),
343 SegmentedItem::new("published".to_string(), "已发布"),
344 SegmentedItem::new("draft".to_string(), "草稿"),
345 ]
346 value=tab_value
347 aria_label="文章状态示例"
348 on_change=move |value: String| tab_value.set(value)
349 />
350 {detail
351 .then(|| {
352 view! {
353 <div class="w-full">
354 <SegmentedControl
355 items=segmented_mode_items()
356 value=seg_mode
357 kind=SegmentedKind::Radio
358 size=SegmentedSize::Sm
359 aria_label="外观示例(宽)"
360 on_change=move |value: String| seg_mode.set(value)
361 />
362 </div>
363 <div style="width: 11rem">
364 <SegmentedControl
365 items=segmented_mode_items()
366 value=seg_mode
367 kind=SegmentedKind::Radio
368 size=SegmentedSize::Sm
369 aria_label="外观示例(窄)"
370 on_change=move |value: String| seg_mode.set(value)
371 />
372 </div>
373 }
374 })}
375 </div>
376 }
377 .into_any(),
378 "Pagination" => view! {
379 <div class="w-full max-w-md" inert="true">
380 <Pagination
381 current_page=2
382 total=80_i64
383 per_page=10
384 prev_route=Route::Home.to_string()
385 next_route=Route::HomePage { page: 3 }.to_string()
386 />
387 </div>
388 }
389 .into_any(),
390 "TablePager" => view! {
391 <div class="admin-panel w-full max-w-md">
392 <TablePager
393 page=page
394 total=57_i64
395 per_page=20
396 unit="篇"
397 on_change=move |value: i32| page.set(value)
398 />
399 </div>
400 }
401 .into_any(),
402 "Tooltip" => view! {
403 <div class="showcase-preview-row">
404 <Tooltip tip="复制链接">
405 <button class="btn btn-icon btn-secondary" aria-label="复制链接">
406 <Icon name=IconName::Copy/>
407 </button>
408 </Tooltip>
409 <Tooltip tip="搜索" shortcut="⌘K">
410 <button class="btn btn-icon btn-secondary" aria-label="搜索">
411 <Icon name=IconName::Search/>
412 </button>
413 </Tooltip>
414 <Tooltip tip="向右旋转" shortcut="R" placement="bottom">
415 <button class="btn btn-icon btn-secondary" aria-label="向右旋转">
416 <Icon name=IconName::RotateCw/>
417 </button>
418 </Tooltip>
419 </div>
420 }
421 .into_any(),
422 "Popover" => view! {
423 <div class="showcase-popover-example">
424 <button
425 class="showcase-demo-button secondary"
426 on:click=move |event: MouseEvent| {
427 event.stop_propagation();
428 popover_x.set(event.client_x());
429 popover_y.set(event.client_y() - event.offset_y());
430 popover_open.update(|open| *open = !*open);
431 }
432 >
433 "试试浮层 ↗"
434 </button>
435 <Popover
436 open=popover_open
437 anchor_x=popover_x
438 anchor_y=popover_y
439 placement="top"
440 align="center"
441 on_close=move |()| popover_open.set(false)
442 >
443 <div class="showcase-popover-content">
444 <p>"要保留这片小小的灵感吗?"</p>
445 <div class="showcase-popover-actions">
446 <button
447 class="showcase-demo-button secondary"
448 on:click=move |_| popover_open.set(false)
449 >
450 "取消"
451 </button>
452 <button
453 class="showcase-demo-button"
454 on:click=move |_| popover_open.set(false)
455 >
456 "保留"
457 </button>
458 </div>
459 </div>
460 </Popover>
461 </div>
462 }
463 .into_any(),
464 "ModalShell" => view! {
465 <div>
466 <button
467 class="showcase-demo-button secondary"
468 on:click=move |_| modal_open.set(true)
469 >
470 "打开一扇小窗 ↗"
471 </button>
472 <ModalShell visible=modal_open closing=modal_closing title="留住一片灵感">
473 <div class="showcase-demo-modal-content">
474 <h2>"留住一片灵感。"</h2>
475 <p>"让内容拥有一点专注的空间。"</p>
476 <button
477 class="showcase-demo-button"
478 on:click=move |_| {
479 modal_closing.set(true);
480 modal_open.set(false);
481 }
482 >
483 "收好了"
484 </button>
485 </div>
486 </ModalShell>
487 </div>
488 }
489 .into_any(),
490 "ConfirmDialog" => view! {
491 <div>
492 <button
493 class="showcase-demo-button secondary"
494 on:click=move |_| confirm_open.set(true)
495 >
496 "清空回收站"
497 </button>
498 <ConfirmDialog
499 visible=confirm_open
500 title="清空回收站?"
501 description="3 篇文章会被彻底删除,无法恢复。被这些文章引用的素材会变为未引用。"
502 confirm_label="彻底删除"
503 on_confirm=move |()| {}
504 />
505 </div>
506 }
507 .into_any(),
508 "AlertBox" => view! {
509 <AlertBoxDemo detail=detail alert_variant=alert_variant alert_message=alert_message/>
510 }
511 .into_any(),
512 "StatusBadge" => view! {
513 <div class="showcase-preview-row">
514 <StatusBadge color_class="badge-success" label="已发布"/>
515 <StatusBadge color_class="" label="草稿"/>
516 <StatusBadge color_class="badge-warning" label="待审核"/>
517 <StatusBadge color_class="badge-danger" label="3 天后清除"/>
518 <StatusBadge color_class="badge-clay" label="新"/>
519 </div>
520 }
521 .into_any(),
522 "StatusDot" => view! {
523 <div class="showcase-preview-row">
524 <StatusDot label="已发布" tone="clay"/>
525 <StatusDot label="草稿"/>
526 <StatusDot label="Docker 沙箱" tone="success"/>
527 <StatusDot label="即将过期" tone="warning"/>
528 <StatusDot label="已吊销" tone="danger"/>
529 </div>
530 }
531 .into_any(),
532 "TagChip" => view! {
533 <div class="showcase-preview-row">
534 <TagChip
535 label="设计"
536 to=Route::TagDetail { tag: "设计".to_string() }.to_string()
537 variant="outline"
538 count=12
539 />
540 <TagChip
541 label="灵感"
542 to=Route::TagDetail { tag: "灵感".to_string() }.to_string()
543 variant="solid"
544 />
545 </div>
546 }
547 .into_any(),
548 "UserAvatar" => {
549 let no_avatar: Option<String> = None;
550 view! {
551 <div class="showcase-preview-row">
552 <UserAvatar
553 name="叶子"
554 avatar_url=no_avatar
555 class="w-14 h-14 rounded-full text-xl"
556 />
557 <UserAvatar
558 name="Yggdrasil"
559 avatar_url=Some("/missing-avatar-for-showcase.png".to_string())
560 class="w-10 h-10 rounded-full text-sm"
561 />
562 </div>
563 }
564 .into_any()
565 }
566 "ImagePlaceholder" => view! { <ImagePlaceholder class="w-10 h-10 text-fg-3"/> }.into_any(),
567 "Icon" => view! {
568 <div class="grid grid-cols-8 gap-3 text-fg-2">
569 {IconName::ALL
570 .into_iter()
571 .map(|icon| {
572 view! {
573 <span class="grid place-items-center" title=icon.slug()>
574 <Icon name=icon size=18/>
575 </span>
576 }
577 })
578 .collect_view()}
579 </div>
580 }
581 .into_any(),
582 "EmptyState" => view! {
583 <div class="showcase-empty-preview">
584 <crate::components::empty_state::EmptyState
585 title="这里,等待第一颗种子。"
586 description="写下一个新的开始。"
587 />
588 </div>
589 }
590 .into_any(),
591 "LoadError" => view! {
592 <div class="w-full max-w-xl">
593 <LoadError
594 title="文章暂时没能加载"
595 retrying=clicked
596 on_retry=move |()| clicked.set(true)
597 />
598 </div>
599 }
600 .into_any(),
601 _ => panic!("基础组件缺少预览: {name}"),
602 }
603}
604
605pub(super) fn sample_post() -> Post {
607 let created_at = DateTime::parse_from_rfc3339("2026-09-23T08:00:00Z")
608 .expect("固定的图鉴示例日期必须有效")
609 .with_timezone(&Utc);
610 Post {
611 id: 0,
612 author_id: 0,
613 title: "让想法生根".to_string(),
614 slug: "showcase-example".to_string(),
615 summary: Some("在时间的缝隙里,种下一些文字。".to_string()),
616 content_md: "一片叶子。".to_string(),
617 content_html: Some("<p>一片叶子,也记得来时的风。</p>".to_string()),
618 status: PostStatus::Published,
619 published_at: Some(created_at),
620 created_at,
621 updated_at: created_at,
622 deleted_at: None,
623 tags: vec!["设计".to_string()],
624 cover_image: None,
625 reading_time: 3,
626 word_count: 620,
627 toc_html: None,
628 prev_post: None,
629 next_post: None,
630 }
631}
632
633#[component]
635pub(super) fn SceneComponentPreview(name: String) -> impl IntoView {
636 let cover_preview = name == "PostCover";
637 Effect::new(move |_| {
638 #[cfg(target_arch = "wasm32")]
639 if cover_preview {
640 crate::bridges::globals::init_lightbox(&[".showcase-cover-window"]);
641 }
642 });
643 let inner = match name.as_str() {
644 "PostCover" => view! {
645 <crate::components::post::post_cover::PostCover src="/images/samples/clay-circle.webp"/>
646 }
647 .into_any(),
648 "SearchIconLink" => view! { <crate::components::header::SearchIconLink/> }.into_any(),
649 _ => panic!("场景组件缺少预览: {name}"),
650 };
651 view! {
652 <div
653 class=if cover_preview {
654 "showcase-scene-window showcase-cover-window"
655 } else {
656 "showcase-scene-window"
657 }
658 aria-label=format!("{name} 的静态样例")
659 >
660 <div class="showcase-skeleton-window-bar">
661 <span></span>
662 <span></span>
663 <span></span>
664 </div>
665 <div class="showcase-scene-canvas" inert="true">
666 {inner}
667 </div>
668 </div>
669 }
670}
671
672#[component]
674fn SearchPaletteDemo() -> impl IntoView {
675 let palette = use_search_palette_provider();
676 view! {
677 <div class="showcase-preview-row">
678 <Button
679 variant=ButtonVariant::Secondary
680 icon=IconName::Search
681 onclick=move |_: MouseEvent| palette.open()
682 >
683 "打开搜索面板"
684 </Button>
685 </div>
686 <crate::components::search_palette::SearchPalette shortcut=false/>
687 }
688}
689
690#[component]
692fn ToastDemo() -> impl IntoView {
693 let toast = use_toast();
694 view! {
695 <div class="showcase-preview-row flex-wrap">
696 <Button
697 variant=ButtonVariant::Secondary
698 onclick=move |_: MouseEvent| toast.show(ToastTone::Info, "草稿已自动保存")
699 >
700 "说明"
701 </Button>
702 <Button
703 variant=ButtonVariant::Secondary
704 onclick=move |_: MouseEvent| toast.success("设置已保存")
705 >
706 "成功"
707 </Button>
708 <Button
709 variant=ButtonVariant::Secondary
710 onclick=move |_: MouseEvent| toast.error("保存失败:网络连接中断,请稍后重试")
711 >
712 "错误"
713 </Button>
714 </div>
715 }
716}
717
718#[component]
720fn BottomSheetDemo() -> impl IntoView {
721 let open = RwSignal::new(false);
722 view! {
723 <button
724 id="showcase-sheet-trigger"
725 class="btn btn-secondary"
726 type="button"
727 aria-haspopup="dialog"
728 aria-expanded=move || open.get().to_string()
729 aria-controls="showcase-bottom-sheet"
730 on:click=move |_| open.set(true)
731 >
732 "打开抽屉"
733 </button>
734 <BottomSheet
735 open=open
736 id="showcase-bottom-sheet"
737 trigger_id="showcase-sheet-trigger"
738 label="演示抽屉"
739 >
740 <BottomSheetDemoBody/>
741 </BottomSheet>
742 }
743}
744
745#[component]
747fn SideSheetDemo() -> impl IntoView {
748 let open = RwSignal::new(false);
749 view! {
750 <button
751 id="showcase-side-sheet-trigger"
752 class="btn btn-secondary"
753 type="button"
754 aria-haspopup="dialog"
755 aria-expanded=move || open.get().to_string()
756 aria-controls="showcase-side-sheet"
757 on:click=move |_| open.set(true)
758 >
759 "打开左侧抽屉"
760 </button>
761 <SideSheet
762 open=open
763 id="showcase-side-sheet"
764 trigger_id="showcase-side-sheet-trigger"
765 label="演示抽屉"
766 >
767 <div class="p-3">
768 <BottomSheetDemoBody/>
769 </div>
770 </SideSheet>
771 }
772}
773
774#[component]
776fn ComposerDemo() -> impl IntoView {
777 let query = RwSignal::new(String::new());
778 let submitted = RwSignal::new(String::new());
779 view! {
780 <div class="showcase-preview-stack w-full max-w-md">
781 <form
782 role="search"
783 on:submit=move |event: SubmitEvent| {
784 event.prevent_default();
785 submitted.set(query.get_untracked().trim().to_string());
786 }
787 >
788 <Composer>
789 <Icon name=IconName::Search size=18/>
790 <ComposerInput
791 search=true
792 aria_label="输入栏示例"
793 placeholder="搜索文章、标签…"
794 value=query
795 oninput=move |value: String| query.set(value)
796 />
797 <ComposerSend label="搜索"/>
798 </Composer>
799 </form>
800 {move || {
801 (!submitted.get().is_empty())
802 .then(|| {
803 view! {
804 <p class="text-sm text-fg-2" role="status">
805 {move || format!("已提交:{}", submitted.get())}
806 </p>
807 }
808 })
809 }}
810 </div>
811 }
812}
813
814#[component]
816fn ComposerInputDemo() -> impl IntoView {
817 let text = RwSignal::new(String::new());
818 let entries = RwSignal::new(Vec::<String>::new());
819 view! {
820 <div class="showcase-preview-stack w-full max-w-md">
821 <Composer size=ComposerSize::Sm>
822 <ComposerInput
823 multiline=true
824 aria_label="多行输入示例"
825 placeholder="写点什么…(⌘↵ 提交)"
826 value=text
827 oninput=move |value: String| text.set(value)
828 on_submit=move |()| {
829 let entry = text.get_untracked().trim().to_string();
830 if !entry.is_empty() {
831 entries.update(|list| list.push(entry));
832 text.set(String::new());
833 }
834 }
835 />
836 </Composer>
837 <ul class="flex flex-col gap-1 text-sm text-fg-2" aria-label="已提交内容">
838 {move || {
839 entries
840 .get()
841 .into_iter()
842 .map(|entry| view! { <li>{entry}</li> })
843 .collect_view()
844 }}
845 </ul>
846 </div>
847 }
848}
849
850#[component]
852fn ComposerSendDemo() -> impl IntoView {
853 let sent = RwSignal::new(0u32);
854 view! {
855 <div class="showcase-preview-stack">
856 <div class="showcase-preview-row">
857 <ComposerSend
858 label="发送"
859 onclick=move |_: MouseEvent| sent.update(|count| *count += 1)
860 />
861 <ComposerSend label="发送(不可用)" disabled=true/>
862 </div>
863 {move || {
864 (sent.get() > 0)
865 .then(|| {
866 view! {
867 <p class="text-sm text-fg-2" role="status">
868 {move || format!("已点击 {} 次", sent.get())}
869 </p>
870 }
871 })
872 }}
873 </div>
874 }
875}
876
877#[component]
879fn AdminPaletteDemo() -> impl IntoView {
880 let palette = use_search_palette_provider();
881 let picked = RwSignal::new(String::new());
882 let created_at = sample_post().created_at;
883 let posts: Vec<PostListItem> = [
884 (1, "让想法生根", PostStatus::Published),
885 (2, "Dioxus 服务端渲染的水合细节", PostStatus::Draft),
886 (3, "用 Rust 写一个 6502 模拟器", PostStatus::Published),
887 ]
888 .into_iter()
889 .map(|(id, title, status)| PostListItem {
890 id,
891 author_id: 0,
892 title: title.to_string(),
893 slug: format!("showcase-{id}"),
894 summary: None,
895 published_at: (status == PostStatus::Published).then_some(created_at),
896 status,
897 created_at,
898 updated_at: created_at,
899 deleted_at: None,
900 tags: Vec::new(),
901 cover_image: None,
902 reading_time: 3,
903 word_count: 620,
904 })
905 .collect();
906 let search = move |query: String| -> PostSearch {
907 let hits = posts
908 .iter()
909 .filter(|post| contains_ci(&post.title, &query))
910 .cloned()
911 .collect();
912 Box::pin(std::future::ready(Some(hits)))
913 };
914 view! {
915 <div class="showcase-preview-row flex-wrap">
916 <Button
917 variant=ButtonVariant::Secondary
918 icon=IconName::Search
919 onclick=move |_: MouseEvent| palette.open()
920 >
921 "打开命令面板"
922 </Button>
923 {move || {
924 (!picked.get().is_empty())
925 .then(|| {
926 view! {
927 <p class="text-xs text-fg-3 font-mono">
928 {move || format!("on_activate: {}", picked.get())}
929 </p>
930 }
931 })
932 }}
933 </div>
934 <AdminPaletteView
935 search=search
936 on_activate=move |item: Item| picked.set(item.title())
937 />
938 }
939}
940
941#[component]
942fn BottomSheetDemoBody() -> impl IntoView {
943 let sheet = use_sheet_close();
944 view! {
945 <ul class="flex flex-col gap-0.5">
946 {["设计系统", "Dioxus 实践", "Rust 异步"]
947 .into_iter()
948 .map(|label| {
949 view! {
950 <li>
951 <button
952 class="sheet-nav-item"
953 type="button"
954 on:click=move |_| sheet.close()
955 >
956 {label}
957 </button>
958 </li>
959 }
960 })
961 .collect_view()}
962 </ul>
963 }
964}