Add GTK app shell with wizard navigation, step indicator, and actions
7-step wizard flow (Workflow, Images, Resize, Convert, Compress, Metadata, Output) with AdwNavigationView, step indicator dots, Back/Next buttons, keyboard shortcuts (Alt+arrows, Alt+1-9), and hamburger menu with Settings and History placeholders.
This commit is contained in:
282
pixstrip-gtk/src/app.rs
Normal file
282
pixstrip-gtk/src/app.rs
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
use adw::prelude::*;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::step_indicator::StepIndicator;
|
||||||
|
use crate::wizard::WizardState;
|
||||||
|
|
||||||
|
pub const APP_ID: &str = "live.lashman.Pixstrip";
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct WizardUi {
|
||||||
|
nav_view: adw::NavigationView,
|
||||||
|
step_indicator: StepIndicator,
|
||||||
|
back_button: gtk::Button,
|
||||||
|
next_button: gtk::Button,
|
||||||
|
title: adw::WindowTitle,
|
||||||
|
pages: Vec<adw::NavigationPage>,
|
||||||
|
state: Rc<RefCell<WizardState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_app() -> adw::Application {
|
||||||
|
let app = adw::Application::builder()
|
||||||
|
.application_id(APP_ID)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
app.connect_activate(build_ui);
|
||||||
|
setup_shortcuts(&app);
|
||||||
|
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_shortcuts(app: &adw::Application) {
|
||||||
|
app.set_accels_for_action("win.next-step", &["<Alt>Right"]);
|
||||||
|
app.set_accels_for_action("win.prev-step", &["<Alt>Left"]);
|
||||||
|
app.set_accels_for_action("win.process", &["<Control>Return"]);
|
||||||
|
for i in 1..=9 {
|
||||||
|
app.set_accels_for_action(
|
||||||
|
&format!("win.goto-step({})", i),
|
||||||
|
&[&format!("<Alt>{}", i)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
app.set_accels_for_action("win.add-files", &["<Control>o"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_ui(app: &adw::Application) {
|
||||||
|
let state = Rc::new(RefCell::new(WizardState::new()));
|
||||||
|
|
||||||
|
// Header bar
|
||||||
|
let header = adw::HeaderBar::new();
|
||||||
|
let title = adw::WindowTitle::new("Pixstrip", "Batch Image Processor");
|
||||||
|
header.set_title_widget(Some(&title));
|
||||||
|
|
||||||
|
// Hamburger menu
|
||||||
|
let menu = build_menu();
|
||||||
|
let menu_button = gtk::MenuButton::builder()
|
||||||
|
.icon_name("open-menu-symbolic")
|
||||||
|
.menu_model(&menu)
|
||||||
|
.primary(true)
|
||||||
|
.tooltip_text("Main Menu")
|
||||||
|
.build();
|
||||||
|
header.pack_end(&menu_button);
|
||||||
|
|
||||||
|
// Step indicator
|
||||||
|
let step_indicator = StepIndicator::new(&state.borrow().step_names());
|
||||||
|
|
||||||
|
// Navigation view for wizard content
|
||||||
|
let nav_view = adw::NavigationView::new();
|
||||||
|
nav_view.set_vexpand(true);
|
||||||
|
|
||||||
|
// Build wizard pages
|
||||||
|
let pages = crate::wizard::build_wizard_pages();
|
||||||
|
for page in &pages {
|
||||||
|
nav_view.add(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom action bar
|
||||||
|
let back_button = gtk::Button::builder()
|
||||||
|
.label("Back")
|
||||||
|
.tooltip_text("Go to previous step (Alt+Left)")
|
||||||
|
.build();
|
||||||
|
back_button.add_css_class("flat");
|
||||||
|
|
||||||
|
let next_button = gtk::Button::builder()
|
||||||
|
.label("Next")
|
||||||
|
.tooltip_text("Go to next step (Alt+Right)")
|
||||||
|
.build();
|
||||||
|
next_button.add_css_class("suggested-action");
|
||||||
|
|
||||||
|
let bottom_box = gtk::CenterBox::new();
|
||||||
|
bottom_box.set_start_widget(Some(&back_button));
|
||||||
|
bottom_box.set_end_widget(Some(&next_button));
|
||||||
|
bottom_box.set_margin_start(12);
|
||||||
|
bottom_box.set_margin_end(12);
|
||||||
|
bottom_box.set_margin_top(6);
|
||||||
|
bottom_box.set_margin_bottom(6);
|
||||||
|
|
||||||
|
let bottom_bar = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||||
|
let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
|
||||||
|
bottom_bar.append(&separator);
|
||||||
|
bottom_bar.append(&bottom_box);
|
||||||
|
|
||||||
|
// Main content layout
|
||||||
|
let content_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||||
|
content_box.append(step_indicator.widget());
|
||||||
|
content_box.append(&nav_view);
|
||||||
|
|
||||||
|
// Toolbar view with header and bottom bar
|
||||||
|
let toolbar_view = adw::ToolbarView::new();
|
||||||
|
toolbar_view.add_top_bar(&header);
|
||||||
|
toolbar_view.set_content(Some(&content_box));
|
||||||
|
toolbar_view.add_bottom_bar(&bottom_bar);
|
||||||
|
|
||||||
|
// Window
|
||||||
|
let window = adw::ApplicationWindow::builder()
|
||||||
|
.application(app)
|
||||||
|
.default_width(900)
|
||||||
|
.default_height(700)
|
||||||
|
.content(&toolbar_view)
|
||||||
|
.title("Pixstrip")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let ui = WizardUi {
|
||||||
|
nav_view,
|
||||||
|
step_indicator,
|
||||||
|
back_button,
|
||||||
|
next_button,
|
||||||
|
title,
|
||||||
|
pages,
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
|
||||||
|
setup_window_actions(&window, &ui);
|
||||||
|
update_nav_buttons(&ui.state.borrow(), &ui.back_button, &ui.next_button);
|
||||||
|
ui.step_indicator.set_current(0);
|
||||||
|
|
||||||
|
window.present();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_menu() -> gtk::gio::Menu {
|
||||||
|
let menu = gtk::gio::Menu::new();
|
||||||
|
menu.append(Some("Settings"), Some("win.show-settings"));
|
||||||
|
menu.append(Some("History"), Some("win.show-history"));
|
||||||
|
menu
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_window_actions(window: &adw::ApplicationWindow, ui: &WizardUi) {
|
||||||
|
let action_group = gtk::gio::SimpleActionGroup::new();
|
||||||
|
|
||||||
|
// Next step action
|
||||||
|
{
|
||||||
|
let ui = ui.clone();
|
||||||
|
let action = gtk::gio::SimpleAction::new("next-step", None);
|
||||||
|
action.connect_activate(move |_, _| {
|
||||||
|
let mut s = ui.state.borrow_mut();
|
||||||
|
if s.can_go_next() {
|
||||||
|
s.go_next();
|
||||||
|
let idx = s.current_step;
|
||||||
|
drop(s);
|
||||||
|
navigate_to_step(&ui, idx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
action_group.add_action(&action);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Previous step action
|
||||||
|
{
|
||||||
|
let ui = ui.clone();
|
||||||
|
let action = gtk::gio::SimpleAction::new("prev-step", None);
|
||||||
|
action.connect_activate(move |_, _| {
|
||||||
|
let mut s = ui.state.borrow_mut();
|
||||||
|
if s.can_go_back() {
|
||||||
|
s.go_back();
|
||||||
|
let idx = s.current_step;
|
||||||
|
drop(s);
|
||||||
|
navigate_to_step(&ui, idx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
action_group.add_action(&action);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go to specific step action
|
||||||
|
{
|
||||||
|
let ui = ui.clone();
|
||||||
|
let action = gtk::gio::SimpleAction::new(
|
||||||
|
"goto-step",
|
||||||
|
Some(&i32::static_variant_type()),
|
||||||
|
);
|
||||||
|
action.connect_activate(move |_, param| {
|
||||||
|
if let Some(step) = param.and_then(|p| p.get::<i32>()) {
|
||||||
|
let target = (step - 1) as usize;
|
||||||
|
let s = ui.state.borrow();
|
||||||
|
if target < s.total_steps && s.visited[target] {
|
||||||
|
drop(s);
|
||||||
|
ui.state.borrow_mut().current_step = target;
|
||||||
|
navigate_to_step(&ui, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
action_group.add_action(&action);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process action (placeholder)
|
||||||
|
{
|
||||||
|
let action = gtk::gio::SimpleAction::new("process", None);
|
||||||
|
action.connect_activate(move |_, _| {});
|
||||||
|
action_group.add_action(&action);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add files action (placeholder)
|
||||||
|
{
|
||||||
|
let action = gtk::gio::SimpleAction::new("add-files", None);
|
||||||
|
action.connect_activate(move |_, _| {});
|
||||||
|
action_group.add_action(&action);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings action (placeholder)
|
||||||
|
{
|
||||||
|
let action = gtk::gio::SimpleAction::new("show-settings", None);
|
||||||
|
action.connect_activate(move |_, _| {});
|
||||||
|
action_group.add_action(&action);
|
||||||
|
}
|
||||||
|
|
||||||
|
// History action (placeholder)
|
||||||
|
{
|
||||||
|
let action = gtk::gio::SimpleAction::new("show-history", None);
|
||||||
|
action.connect_activate(move |_, _| {});
|
||||||
|
action_group.add_action(&action);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect button clicks
|
||||||
|
ui.back_button.connect_clicked({
|
||||||
|
let action_group = action_group.clone();
|
||||||
|
move |_| {
|
||||||
|
ActionGroupExt::activate_action(&action_group, "prev-step", None);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.next_button.connect_clicked({
|
||||||
|
let action_group = action_group.clone();
|
||||||
|
move |_| {
|
||||||
|
ActionGroupExt::activate_action(&action_group, "next-step", None);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.insert_action_group("win", Some(&action_group));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn navigate_to_step(ui: &WizardUi, target: usize) {
|
||||||
|
let s = ui.state.borrow();
|
||||||
|
|
||||||
|
// Update step indicator
|
||||||
|
ui.step_indicator.set_current(target);
|
||||||
|
for (i, visited) in s.visited.iter().enumerate() {
|
||||||
|
if *visited && i != target {
|
||||||
|
ui.step_indicator.set_completed(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate - replace entire stack up to target
|
||||||
|
if target < ui.pages.len() {
|
||||||
|
ui.nav_view.replace(&ui.pages[..=target]);
|
||||||
|
ui.title.set_subtitle(&ui.pages[target].title());
|
||||||
|
}
|
||||||
|
|
||||||
|
update_nav_buttons(&s, &ui.back_button, &ui.next_button);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_nav_buttons(state: &WizardState, back_button: >k::Button, next_button: >k::Button) {
|
||||||
|
back_button.set_sensitive(state.can_go_back());
|
||||||
|
back_button.set_visible(state.current_step > 0);
|
||||||
|
|
||||||
|
if state.is_last_step() {
|
||||||
|
next_button.set_label("Process");
|
||||||
|
next_button.remove_css_class("suggested-action");
|
||||||
|
next_button.add_css_class("suggested-action");
|
||||||
|
next_button.set_tooltip_text(Some("Start processing (Ctrl+Enter)"));
|
||||||
|
} else {
|
||||||
|
next_button.set_label("Next");
|
||||||
|
next_button.add_css_class("suggested-action");
|
||||||
|
next_button.set_tooltip_text(Some("Go to next step (Alt+Right)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,38 +1,10 @@
|
|||||||
use adw::prelude::*;
|
mod app;
|
||||||
|
mod step_indicator;
|
||||||
|
mod wizard;
|
||||||
|
|
||||||
const APP_ID: &str = "live.lashman.Pixstrip";
|
use gtk::prelude::*;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let app = adw::Application::builder()
|
let app = app::build_app();
|
||||||
.application_id(APP_ID)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
app.connect_activate(build_ui);
|
|
||||||
app.run();
|
app.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_ui(app: &adw::Application) {
|
|
||||||
let header = adw::HeaderBar::new();
|
|
||||||
|
|
||||||
let title = adw::WindowTitle::new("Pixstrip", "Batch Image Processor");
|
|
||||||
header.set_title_widget(Some(&title));
|
|
||||||
|
|
||||||
let content = adw::StatusPage::builder()
|
|
||||||
.title("Pixstrip")
|
|
||||||
.description(format!("v{}", pixstrip_core::version()).as_str())
|
|
||||||
.icon_name("image-x-generic-symbolic")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let toolbar_view = adw::ToolbarView::new();
|
|
||||||
toolbar_view.add_top_bar(&header);
|
|
||||||
toolbar_view.set_content(Some(&content));
|
|
||||||
|
|
||||||
let window = adw::ApplicationWindow::builder()
|
|
||||||
.application(app)
|
|
||||||
.default_width(900)
|
|
||||||
.default_height(650)
|
|
||||||
.content(&toolbar_view)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
window.present();
|
|
||||||
}
|
|
||||||
|
|||||||
126
pixstrip-gtk/src/step_indicator.rs
Normal file
126
pixstrip-gtk/src/step_indicator.rs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
use gtk::prelude::*;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct StepIndicator {
|
||||||
|
container: gtk::Box,
|
||||||
|
dots: RefCell<Vec<StepDot>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct StepDot {
|
||||||
|
button: gtk::Button,
|
||||||
|
icon: gtk::Image,
|
||||||
|
label: gtk::Label,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StepIndicator {
|
||||||
|
pub fn new(step_names: &[String]) -> Self {
|
||||||
|
let container = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.spacing(0)
|
||||||
|
.margin_top(8)
|
||||||
|
.margin_bottom(8)
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut dots = Vec::new();
|
||||||
|
|
||||||
|
for (i, name) in step_names.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
// Connector line between dots
|
||||||
|
let line = gtk::Separator::builder()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.hexpand(false)
|
||||||
|
.valign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
line.set_size_request(24, -1);
|
||||||
|
container.append(&line);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dot_box = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(2)
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let icon = gtk::Image::builder()
|
||||||
|
.icon_name("radio-symbolic")
|
||||||
|
.pixel_size(16)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let button = gtk::Button::builder()
|
||||||
|
.child(&icon)
|
||||||
|
.has_frame(false)
|
||||||
|
.tooltip_text(format!("Step {}: {}", i + 1, name))
|
||||||
|
.sensitive(false)
|
||||||
|
.build();
|
||||||
|
button.add_css_class("circular");
|
||||||
|
|
||||||
|
let label = gtk::Label::builder()
|
||||||
|
.label(name)
|
||||||
|
.css_classes(["caption"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
dot_box.append(&button);
|
||||||
|
dot_box.append(&label);
|
||||||
|
container.append(&dot_box);
|
||||||
|
|
||||||
|
dots.push(StepDot {
|
||||||
|
button,
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// First step starts as current
|
||||||
|
if let Some(first) = dots.first() {
|
||||||
|
first.icon.set_icon_name(Some("radio-checked-symbolic"));
|
||||||
|
first.button.set_sensitive(true);
|
||||||
|
first.label.add_css_class("accent");
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
container,
|
||||||
|
dots: RefCell::new(dots),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_current(&self, index: usize) {
|
||||||
|
let dots = self.dots.borrow();
|
||||||
|
for (i, dot) in dots.iter().enumerate() {
|
||||||
|
if i == index {
|
||||||
|
dot.icon.set_icon_name(Some("radio-checked-symbolic"));
|
||||||
|
dot.button.set_sensitive(true);
|
||||||
|
dot.label.add_css_class("accent");
|
||||||
|
} else if dot.icon.icon_name().as_deref() != Some("emblem-ok-symbolic") {
|
||||||
|
dot.icon.set_icon_name(Some("radio-symbolic"));
|
||||||
|
dot.label.remove_css_class("accent");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_completed(&self, index: usize) {
|
||||||
|
let dots = self.dots.borrow();
|
||||||
|
if let Some(dot) = dots.get(index) {
|
||||||
|
dot.icon.set_icon_name(Some("emblem-ok-symbolic"));
|
||||||
|
dot.button.set_sensitive(true);
|
||||||
|
dot.label.remove_css_class("accent");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn widget(&self) -> >k::Box {
|
||||||
|
&self.container
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow appending the step indicator to containers
|
||||||
|
impl std::ops::Deref for StepIndicator {
|
||||||
|
type Target = gtk::Box;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.container
|
||||||
|
}
|
||||||
|
}
|
||||||
88
pixstrip-gtk/src/wizard.rs
Normal file
88
pixstrip-gtk/src/wizard.rs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
pub struct WizardState {
|
||||||
|
pub current_step: usize,
|
||||||
|
pub total_steps: usize,
|
||||||
|
pub visited: Vec<bool>,
|
||||||
|
names: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WizardState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let names = vec![
|
||||||
|
"Workflow".into(),
|
||||||
|
"Images".into(),
|
||||||
|
"Resize".into(),
|
||||||
|
"Convert".into(),
|
||||||
|
"Compress".into(),
|
||||||
|
"Metadata".into(),
|
||||||
|
"Output".into(),
|
||||||
|
];
|
||||||
|
let total = names.len();
|
||||||
|
let mut visited = vec![false; total];
|
||||||
|
visited[0] = true;
|
||||||
|
|
||||||
|
Self {
|
||||||
|
current_step: 0,
|
||||||
|
total_steps: total,
|
||||||
|
visited,
|
||||||
|
names,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn step_names(&self) -> Vec<String> {
|
||||||
|
self.names.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_go_next(&self) -> bool {
|
||||||
|
self.current_step < self.total_steps - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_go_back(&self) -> bool {
|
||||||
|
self.current_step > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_last_step(&self) -> bool {
|
||||||
|
self.current_step == self.total_steps - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn go_next(&mut self) {
|
||||||
|
if self.can_go_next() {
|
||||||
|
self.current_step += 1;
|
||||||
|
self.visited[self.current_step] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn go_back(&mut self) {
|
||||||
|
if self.can_go_back() {
|
||||||
|
self.current_step -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_wizard_pages() -> Vec<adw::NavigationPage> {
|
||||||
|
let steps = [
|
||||||
|
("step-workflow", "Choose a Workflow", "image-x-generic-symbolic", "Select a preset or build a custom workflow"),
|
||||||
|
("step-images", "Add Images", "folder-pictures-symbolic", "Drop images here or click Browse"),
|
||||||
|
("step-resize", "Resize", "view-fullscreen-symbolic", "Set output dimensions"),
|
||||||
|
("step-convert", "Convert", "document-save-symbolic", "Choose output format"),
|
||||||
|
("step-compress", "Compress", "system-file-manager-symbolic", "Set compression quality"),
|
||||||
|
("step-metadata", "Metadata", "security-high-symbolic", "Control metadata privacy"),
|
||||||
|
("step-output", "Output & Process", "emblem-ok-symbolic", "Review and process"),
|
||||||
|
];
|
||||||
|
|
||||||
|
steps
|
||||||
|
.iter()
|
||||||
|
.map(|(tag, title, icon, description)| {
|
||||||
|
let status_page = adw::StatusPage::builder()
|
||||||
|
.title(*title)
|
||||||
|
.description(*description)
|
||||||
|
.icon_name(*icon)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
adw::NavigationPage::builder()
|
||||||
|
.title(*title)
|
||||||
|
.tag(*tag)
|
||||||
|
.child(&status_page)
|
||||||
|
.build()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user