Compare commits

...

1 Commits

Author SHA1 Message Date
Ilia Ross
cbe1f7f17e Add single page layout to Framed Theme
This PR adds a "Page layout" setting to the Framed Theme, with a new single page layout that shows the menu on every page instead of in a frame.

It's meant for people who can't or don't want to use frames, so the Legacy Theme is no longer the only option without frames.

The menu code moved out of `left.cgi` into a shared menu-lib.pl, so both layouts build the same menu, frames look the same as before, and `left.cgi` shrinks from about 400 lines to about 60.

The menu's module search box now follows the global "webminsearch" permission, which the old frame code never actually checked.

One exception to be aware of. A server opened through Webmin Servers Index always uses frames, because that index only rewrites the links and redirects of framed pages.
2026-10-01 05:44:49 +02:00
13 changed files with 1693 additions and 413 deletions

View File

@@ -16,3 +16,4 @@ applet_light_bg=ffffff
noindex=1
inframe=1
framed=1
oofunctions=theme-init.pl

View File

@@ -50,6 +50,12 @@ if ($hasvirt || $hasvm2) {
[ "webmin", $text{'edright_webmin'} ] ]));
}
# Menu in a frame of its own, or on every page
print &ui_table_row($text{'edright_layout'},
&ui_select("layout", $sects->{'layout'},
[ [ "", $text{'edright_layout_frames'} ],
[ "single", $text{'edright_layout_single'} ] ]));
# Left frame size
print &ui_table_row($text{'edright_fsize'},
&ui_opt_textbox("fsize", $sects->{'fsize'}, 6, $text{'edright_fsizedef'}).

View File

@@ -12,14 +12,11 @@ $right_frame_sections_file = "$config_directory/$current_theme/sections";
$default_domains_to_show = 10;
# get_left_frame_width()
# Returns the width of the left frame in pixels
# Returns the width of the left frame in pixels, the same as the width of
# the menu in the single page layout
sub get_left_frame_width
{
local $sects = &get_right_frame_sections();
return $sects->{'fsize'} ? $sects->{'fsize'} :
&get_product_name() eq 'usermin' ? 200 :
&foreign_available("server-manager") &&
&foreign_available("virtual-server") ? 280 : 260;
return &theme_menu_width();
}
# list_virtualmin_theme_overlays()

View File

@@ -46,6 +46,13 @@ if (!$goto) {
}
}
if (&theme_single_page()) {
# The single page layout has no frameset, so the first page opens by
# itself and brings its own menu
&theme_local_redirect("/$goto");
exit;
}
# Work out the title that includes the version
if ($hasvirt) {
%minfo = &get_module_info("virtual-server");
@@ -107,20 +114,7 @@ print "<link rel='stylesheet' type='text/css' href='".&get_webprefix().
&theme_asset_key("gray-theme.css")."'>\n";
print &ui_switch_theme_javascript();
print "<title>$title</title>\n";
my $imgdir = "@{[&get_webprefix()]}/images";
my $prod = 'webmin';
if (foreign_available("server-manager")) {
$prod = 'cloudmin';
}
elsif (foreign_available("virtual-server")) {
$prod = 'virtualmin';
}
elsif (get_product_name() eq 'usermin') {
$prod = 'usermin';
}
print "<link rel='icon' type='image/png' sizes='16x16' href='$imgdir/favicons/$prod/favicon-16x16.png'>\n";
print "<link rel='icon' type='image/png' sizes='32x32' href='$imgdir/favicons/$prod/favicon-32x32.png'>\n";
print "<link rel='icon' type='image/png' sizes='192x192' href='$imgdir/favicons/$prod/favicon-192x192.png'>\n";
print &theme_favicons();
print "</head>\n";
# Upper custom frame

View File

@@ -3,6 +3,8 @@ left_search=Search
left_dom=Virtual server
left_sid=Managed system
left_dname=Virtual server
left_menu=Menu
left_elib=Failed to load the theme menu : $1
right_virtver=Virtualmin version $1 or higher is now required by this theme!
right_vm2ver=Cloudmin version $1 or higher is now required by this theme!
@@ -57,11 +59,15 @@ edright_tab1=Whichever is first
edright_virtualmin=Virtualmin
edright_vm2=Cloudmin
edright_webmin=Webmin
edright_fsize=Left frame width
edright_fsize=Left menu width
edright_search=Show search box below left menu?
edright_fsizedef=Decide automatically
edright_pixels=pixels
edright_efsize=Missing or invalid left frame width in pixels
edright_efsize=Missing or invalid left menu width in pixels
edright_layout=Page layout
edright_layout_frames=Menu in its own frame
edright_layout_single=Menu on every page, without frames
edright_elayout=Invalid page layout
edright_scheme=Color scheme
edright_scheme_auto=Follow the browser setting
edright_scheme_light=Light

View File

@@ -1,5 +1,6 @@
#!/usr/local/bin/perl
# Show the left-side menu of Virtualmin domains, plus modules
# Show the menu in the left frame of the frameset: the Virtualmin or Cloudmin
# menu, or the Webmin modules
use strict;
use warnings;
no warnings 'redefine';
@@ -8,238 +9,25 @@ no warnings 'uninitialized';
# Globals
our %in;
our %text;
our $base_remote_user;
our $remote_user;
our %miniserv;
our %gaccess;
our $session_id;
# The menu only shows links and changes nothing, so it loads without the
# referer check
our $trust_unknown_referers = 1;
# Load the theme and the functions that build the menu
require "gray-theme/gray-theme-lib.pl";
require "gray-theme/theme.pl";
ReadParse();
theme_menu_lib() || error(text('left_elib', html_escape($@)));
# The body class selects the left frame styles
our $gray_theme_body_class = 'left-frame';
my $is_master;
# Is this user the master administrator? Virtualmin and Cloudmin each
# have their own test for it
if (foreign_available("virtual-server")) {
# Virtualmin's master admin
foreign_require("virtual-server");
$is_master = virtual_server::master_admin();
}
elsif (foreign_available("server-manager")) {
# Cloudmin's user with global permissions
foreign_require("server-manager");
$is_master = server_manager::can_action(undef, "global");
}
# Find all left-side items from Webmin
my $sects = get_right_frame_sections();
my @leftitems = list_combined_webmin_menu($sects, \%in);
my @lefttitles = grep { $_->{'type'} eq 'title' } @leftitems;
# Work out what mode selector contains
my @has = ( );
my %modmenu;
foreach my $title (@lefttitles) {
push(@has, { 'id' => $title->{'module'},
'desc' => $title->{'desc'} });
$modmenu{$title->{'module'}}++;
}
my $nw = $sects->{'nowebmin'} || 0;
if ($nw == 0 || $nw == 2 && $is_master) {
# The Webmin modules mode is offered unless the settings hide it,
# from everyone or from all but the master
my $p = get_product_name();
push(@has, { 'id' => 'modules',
'desc' => $text{'has_'.$p} });
}
# Default left-side mode
my $mode = $in{'mode'} ? $in{'mode'} :
$sects->{'tab'} && $sects->{'tab'} =~ /vm2/ ? "server-manager" :
$sects->{'tab'} && $sects->{'tab'} =~ /virtualmin/ ? "virtual-server" :
$sects->{'tab'} && $sects->{'tab'} =~ /mail/ ? "mailboxes" :
$sects->{'tab'} && $sects->{'tab'} =~ /webmin/ ? "modules" :
@leftitems ? $has[0]->{'id'} : "modules";
if (indexof($mode, (map { $_->{'id'} } @has)) < 0) {
# A mode that is not offered falls back to the first one
$mode = $has[0]->{'id'};
}
# Product shown in the brand block, and its name
my $prod = foreign_available("server-manager") ? 'cloudmin' :
foreign_available("virtual-server") ? 'virtualmin' :
get_product_name() eq 'usermin' ? 'usermin' : 'webmin';
my %prodnames = ( 'cloudmin' => $text{'has_vm2'},
'virtualmin' => $text{'has_virtualmin'},
'usermin' => $text{'has_usermin'},
'webmin' => $text{'has_webmin'} );
popup_header($prodnames{$prod});
# The whole menu sits in one panel that floats on the page ground
print "<div class='menu-panel'>\n";
if ($mode eq "modules") {
# Only showing Webmin modules
@leftitems = &list_modules_webmin_menu();
foreach my $l (@leftitems) {
$l->{'members'} = [ grep { !$modmenu{$_->{'id'}} } @{$l->{'members'}} ];
}
push(@leftitems, { 'type' => 'hr' });
}
else {
# Only show items under some title OR items that have no title
my ($lefttitle) = grep { $_->{'id'} eq $mode } @lefttitles;
my %titlemods = map { $_->{'module'}, $_ } @lefttitles;
@leftitems = grep { $_->{'module'} eq $mode ||
!$titlemods{$_->{'module'}} } @leftitems;
}
@leftitems = grep { $_->{'type'} ne 'title' } @leftitems;
# Lines of text at the start of the menu, such as the login and its level,
# go into the brand block instead of the menu
my @userlines;
while(@leftitems && $leftitems[0]->{'type'} eq 'text') {
my $t = shift(@leftitems);
if ($t->{'json'} && $t->{'json'}->{'label'}) {
# Login line, shown as the username and its level
push(@userlines, "<b>".html_escape($remote_user)."</b>".
($t->{'json'}->{'level'} ?
" &middot; ".$t->{'json'}->{'level'} : ""));
}
else {
# Any other line of text, as it is
push(@userlines, html_escape($t->{'desc'}));
}
}
if (@userlines && $leftitems[0]->{'type'} eq 'hr') {
# The separator after the text lines goes with them
shift(@leftitems);
}
if (!@userlines) {
# Without a login line, show the username alone
push(@userlines, "<b>".html_escape($remote_user || $base_remote_user).
"</b>");
}
# The brand block and the product switch stay at the top of the panel
# while the menu scrolls under them. The loader is the bar that bounces
# along the top edge while the right frame loads a page.
print "<div class='menu-top'>\n";
print "<div class='menu-loader'></div>\n";
# Show the brand block, with the product logo in a light and a dark
# version for the stylesheet to pick from
my $imgdir = add_webprefix("/images");
my $alt = html_escape($prodnames{$prod});
print "<div class='menu-brand'>\n";
print "<a class='menu-brand-name' href='".add_webprefix("/right.cgi").
"' target='right' title='$alt'>";
print "<img class='menu-brand-logo' src='$imgdir/logos/$prod.svg' ".
"alt='$alt'>";
print "<img class='menu-brand-logo menu-brand-logo-dark' ".
"src='$imgdir/logos/$prod-dark.svg' alt='$alt'>";
print "</a>\n";
# The host and the login, as small lines with icons
print "<div class='menu-meta'>\n";
print "<div class='menu-host'>".ui_svg_icon('server', { 'size' => 13 }).
"<span>".html_escape(get_display_hostname())."</span></div>\n";
foreach my $l (@userlines) {
print "<div class='menu-user'>".ui_svg_icon('user', { 'size' => 13 }).
"<span>$l</span></div>\n";
}
print "</div>\n";
print "</div>\n";
# Show mode selector
if (@has > 1) {
print "<div class='mode'>";
foreach my $m (@has) {
print "<b data-mode='$m->{'id'}'>";
if ($m->{'id'} ne $mode) {
# The click marks the switch as loading until the
# menu has been replaced
print "<a href='left.cgi?mode=$m->{'id'}' ".
"onclick=\"this.parentNode.parentNode.".
"classList.add('loading');".
"this.parentNode.classList.add('active')\">";
}
print $m->{'desc'};
if ($m->{'id'} ne $mode) {
# Close the link of an unselected mode
print "</a>";
}
print "</b>\n";
}
print "</div>\n";
}
print "</div>\n";
# Print the frame page, titled with the product name, and the menu
my (undef, $prodname) = theme_menu_product(\%text);
popup_header($prodname);
print &ui_switch_theme_javascript();
print "<div class='leftmenu'>\n";
# Show Webmin search form
my $cansearch = ($gaccess{'webminsearch'} || '') ne '0' &&
!$sects->{'nosearch'};
if ($mode eq "modules" && $cansearch) {
push(@leftitems, { 'type' => 'input',
'desc' => ' ',
'tags' => " placeholder='$text{'left_search'}'",
'size' => 10,
'name' => 'search',
'cgi' => '/webmin_search.cgi', });
push(@leftitems, { 'type' => 'hr' });
}
# Show system information link
push(@leftitems, { 'type' => 'item',
'id' => 'home',
'desc' => $text{'left_home'},
'link' => '/right.cgi' });
# Show refresh modules link
if ($mode eq "modules" && foreign_available("webmin")) {
push(@leftitems, { 'type' => 'item',
'id' => 'refresh',
'desc' => $text{'main_refreshmods'},
'link' => '/webmin/refresh_modules.cgi' });
}
# Show logout link
get_miniserv_config(\%miniserv);
if ($miniserv{'logout'} && !$ENV{'SSL_USER'} && !$ENV{'LOCAL_USER'} &&
$ENV{'HTTP_USER_AGENT'} !~ /webmin/i) {
my $logout = { 'type' => 'item',
'id' => 'logout',
'target' => 'window' };
if ($main::session_id) {
# Session logins can log out
$logout->{'desc'} = $text{'main_logout'};
$logout->{'link'} = '/session_login.cgi?logout=1';
}
else {
# Other logins can only switch to another user
$logout->{'desc'} = $text{'main_switch'};
$logout->{'link'} = '/switch_user.cgi';
}
push(@leftitems, $logout);
}
# Show link back to original Webmin server
if ($ENV{'HTTP_WEBMIN_SERVERS'}) {
push(@leftitems, { 'type' => 'item',
'desc' => $text{'header_servers'},
'link' => $ENV{'HTTP_WEBMIN_SERVERS'},
'target' => 'window' });
}
show_menu_items_list(\@leftitems, 0);
print "</div>\n";
print "</div>\n";
print theme_menu_html({ 'in' => \%in });
# The loader starts on a click or a form submission aimed at the right
# frame and stops when the new page there reports, through the functions
@@ -272,133 +60,3 @@ document.addEventListener('submit', function(e) {
</script>
EOF
popup_footer();
# show_menu_items_list(&list, indent)
# Actually prints the HTML for menu items
sub show_menu_items_list
{
my ($items, $indent) = @_;
foreach my $item (@$items) {
if ($item->{'type'} eq 'item') {
# Link to some page
my $it = $item->{'target'} || '';
my $t = $it eq 'new' ? '_blank' :
$it eq 'window' ? '_top' : 'right';
my $link = add_webprefix($item->{'link'});
if ($item->{'link'} =~ /^(https?):\/\//) {
# Links to other sites open in a new window
$t = '_blank';
$link = $item->{'link'};
}
my $cls;
if ($item->{'format'} eq 'link-new') {
# Creation link shown as a button with a plus
$cls = 'menu-create';
$item->{'desc'} = ui_svg_icon('plus', { 'size' => 15 }).
" ".$item->{'desc'};
}
else {
# Ordinary link, indented inside a category and muted
# when inactive
$cls = 'menu-link';
$cls .= ' menu-sub' if ($indent);
$cls .= ' inactive' if ($item->{'inactive'});
if ($item->{'id'} eq 'logout') {
# Logout gets an icon and a muted look
$cls .= ' menu-logout';
$item->{'desc'} = ui_svg_icon('power', { 'size' => 14 }).
" ".$item->{'desc'};
}
}
print "<a class='$cls' href='$link' target='$t'>".
"$item->{'desc'}</a>\n";
}
elsif ($item->{'type'} eq 'cat') {
# Start of a new category, opened when requested by the
# frameset page
my $c = $item->{'id'};
print "<details class='menu-cat'".($in{$c} ? " open" : "").">";
print "<summary><span>$item->{'desc'}</span></summary>\n";
show_menu_items_list($item->{'members'}, $indent+1);
print "</details>\n";
}
elsif ($item->{'type'} eq 'html') {
# Some HTML block
print "<div class='menu-html'>",$item->{'html'},"</div>\n";
}
elsif ($item->{'type'} eq 'text') {
# A line of text
print "<div class='menu-text'>",
html_escape($item->{'desc'}),"</div>\n";
}
elsif ($item->{'type'} eq 'hr') {
# Separator line
print "<hr class='menu-divider'>\n";
}
elsif ($item->{'type'} eq 'menu' || $item->{'type'} eq 'input') {
# Form with an input of some kind
if ($item->{'cgi'}) {
# The form submits to the item's CGI in the right frame
my $cgi = add_webprefix($item->{'cgi'});
print "<form class='menu-form' action='$cgi' ".
"target='right'>\n";
}
else {
# Without a CGI, the form reloads this menu with the
# new value
print "<form class='menu-form'>\n";
}
foreach my $h (@{$item->{'hidden'}}) {
print ui_hidden(@$h);
}
print ui_hidden("mode", $mode);
my $label = $item->{'desc'} =~ /\S/ ? $item->{'desc'}
: $text{'left_'.$item->{'name'}};
if ($label) {
# Small caps label above the field
print "<label class='menu-label' for='".
html_escape($item->{'name'})."'>$label</label>\n";
}
print "<div class='menu-field'>\n";
if ($item->{'type'} eq 'menu') {
# A drop-down that submits as soon as it changes
my $sel = "";
if ($item->{'onchange'}) {
# Some menus also load a page for the chosen
# value in the right frame
$sel = "window.parent.frames[1].location = ".
"\"$item->{'onchange'}\" + this.value";
}
print ui_select($item->{'name'}, $item->{'value'},
$item->{'menu'}, 1, 0, 0, 0,
"onChange='form.submit(); $sel'");
}
elsif ($item->{'type'} eq 'input') {
# A text field, such as the search box
print ui_textbox($item->{'name'}, $item->{'value'},
$item->{'size'}, undef, undef, $item->{'tags'});
}
print "</div>\n";
print "</form>\n";
}
}
}
# module_to_menu_item(&module)
# Converts a module to the hash ref format expected by show_menu_items_list
sub module_to_menu_item
{
my ($minfo) = @_;
return { 'type' => 'item',
'id' => $minfo->{'dir'},
'desc' => $minfo->{'desc'},
'link' => '/'.$minfo->{'dir'}.'/' };
}
# add_webprefix(link)
# If a URL starts with a / , add webprefix
sub add_webprefix
{
my ($link) = @_;
return $link =~ /^\// ? &get_webprefix().$link : $link;
}

1059
gray-theme/menu-lib.pl Normal file

File diff suppressed because it is too large Load Diff

101
gray-theme/menu.cgi Executable file
View File

@@ -0,0 +1,101 @@
#!/usr/local/bin/perl
# Remember a choice made in the menu of the single page layout, such as the
# menu mode, a domain or a managed system, and open a page for it. Also
# sends the menu panel to a page that loads it.
use strict;
use warnings;
no warnings 'redefine';
no warnings 'uninitialized';
# Globals; %theme_menu_pages comes from menu-lib.pl
our %in;
our %text;
our %theme_menu_pages;
# The request only sets a cookie and redirects to a page on this server
our $trust_unknown_referers = 1;
# Load the theme and the functions that build the menu
require "gray-theme/gray-theme-lib.pl";
require "gray-theme/theme.pl";
ReadParse();
theme_menu_lib() || error(text('left_elib', html_escape($@)));
if ($in{'fragment'}) {
# The menu panel of a page that runs as a Unix user and loads its menu
# from here: built for that page's address, module and the page the
# browser came from, with the choices it shows stored in the cookie
$ENV{'REQUEST_URI'} = theme_menu_local_url($in{'page'}) ? $in{'page'}
: "";
$ENV{'HTTP_REFERER'} = $in{'ref'};
$main::gray_theme_menu_module = $in{'module'} =~ /^[\w\-]+$/ ?
$in{'module'} : "";
my $html = theme_menu_html({ 'single' => 1, 'fragment' => 1 });
print "Set-Cookie: ".theme_menu_cookie_string(theme_menu_state())."\n";
PrintHeader();
print $html;
exit;
}
# Start from the remembered choices
my %state = %{theme_menu_state()};
if ($in{'mode'} =~ /^[\w.\-]+$/) {
# A new menu mode
$state{'mode'} = $in{'mode'};
}
# Look up a domain or system chosen in a menu field the same way the menu
# does, so only one the menu can show is remembered. Build the menus with
# the remembered value for the other one, so choosing a system leaves the
# remembered domain alone, and the other way around.
my %ask = map { $_, $state{$_} } grep { $state{$_} ne '' } ('dom', 'sid');
my %asked;
foreach my $k ('dom', 'dname', 'sid') {
next if ($in{$k} eq '');
$ask{$k} = $in{$k};
$asked{$k eq 'dname' ? 'dom' : $k}++;
}
if ($in{'dname'} ne '' && $in{'dom'} eq '') {
# The Virtualmin menu looks up a name only when no domain ID is given
delete($ask{'dom'});
}
my %changed;
if (%asked) {
# Build the menus with the new value, and keep what they show for it
my @items = list_combined_webmin_menu(theme_settings(), \%ask);
my $sel = theme_menu_selection(\@items);
foreach my $k (keys %asked) {
next if ($sel->{$k} eq '');
$changed{$k}++ if ($sel->{$k} ne $state{$k});
$state{$k} = $sel->{$k};
}
}
# Work out the page to open next
my $url;
my $field = $in{'field'};
if (theme_menu_local_url($in{'goto'}) && $field =~ /^\w+$/) {
# The field names a page for its value, such as a domain summary
my $value = $field =~ /^(dom|sid)$/ && $state{$field} ne '' ?
$state{$field} : $in{$field};
$url = $in{'goto'}.urlize($value);
}
elsif ($changed{'dom'} || $changed{'sid'}) {
# A page showing the new domain or system
my $k = $changed{'dom'} ? 'dom' : 'sid';
$url = $theme_menu_pages{$k}.urlize($state{$k});
}
elsif (theme_menu_local_url($in{'return'})) {
# Back to the index of the module the menu was on, or to the system
# information page
$url = $in{'return'};
}
else {
# The system information page
$url = "/right.cgi";
}
# Store the choices and redirect with a path only, so the browser stays on
# the host it used
print "Set-Cookie: ".theme_menu_cookie_string(\%state)."\n";
theme_local_redirect($url);

View File

@@ -7,6 +7,10 @@ no warnings 'redefine';
no warnings 'uninitialized';
# The language table of the system status module is read once below
no warnings 'once';
# The page is the start page, which the browser opens without a Referer
# when the address was typed, as / redirects to it in the single page
# layout. A request with a Referer from another site is still refused.
our $trust_unknown_referers = 2;
require "gray-theme/gray-theme-lib.pl";
require "gray-theme/theme.pl";
&ReadParse();
@@ -25,8 +29,10 @@ if ($redir) {
return;
}
# The body class selects the right frame styles
# The body class selects the right frame styles. In the single page layout
# this page shows the menu, even though it uses the popup header.
our $gray_theme_body_class = 'right-frame';
our $gray_theme_menu_page = 1;
&popup_header($text{'left_home'});
# Links from modules become the page actions, with an icon where the

View File

@@ -27,6 +27,9 @@ else {
}
$sect->{'list'} = $in{'list'};
$sect->{'tab'} = $in{'tab'};
# Page layout, framed or a single page
$in{'layout'} =~ /^(single|)$/ || &error($text{'edright_elayout'});
$sect->{'layout'} = $in{'layout'};
if ($in{'fsize_def'}) {
delete($sect->{'fsize'});
}
@@ -61,5 +64,16 @@ if ($hasvirt && &virtual_server::master_admin() ||
# Save config
&save_right_frame_sections($sect);
&redirect("right.cgi");
if ($sect->{'layout'} ne $sects->{'layout'}) {
# The layout changed, so reload the whole window to drop the frameset
# or bring it back
$main::gray_theme_settings = undef;
&popup_header();
print &js_redirect($sect->{'layout'} ? "/right.cgi" : "/", "top");
&popup_footer();
}
else {
# Back to the system information page
&redirect("right.cgi");
}

34
gray-theme/theme-init.pl Normal file
View File

@@ -0,0 +1,34 @@
# Functions that read the theme settings. The core loads this file while it
# sets up every page, as the oofunctions file named in the theme config,
# and theme.pl loads it too. Reading the settings that early matters on
# pages that then switch to a Unix user, such as File Manager for a domain
# owner: that user cannot read the root-only settings files.
# theme_post_init_config()
# Called by the core at the end of init_config, while the page still runs
# as root. Reads the theme settings, which the page then keeps.
sub theme_post_init_config
{
&theme_settings();
}
# theme_settings()
# Returns the theme settings as a hash ref: the user's own, or the global
# ones when those apply to everyone or the user has none. They are stored
# with the system information page settings and read once per page.
sub theme_settings
{
return $main::gray_theme_settings if ($main::gray_theme_settings);
my $file = "$config_directory/$current_theme/sections";
my %sects;
&read_file($file, \%sects);
if (!$sects{'global'}) {
# The user's own file replaces the global settings
my %usects;
%sects = %usects if (&read_file("$file.$remote_user", \%usects));
}
$main::gray_theme_settings = \%sects;
return $main::gray_theme_settings;
}
1;

View File

@@ -16,6 +16,10 @@ $main::nosingledomain_virtualmin_mode = 1;
our $ui_formcount;
# The functions that read the theme settings, unless the core has already
# loaded them into this package
do "$theme_root_directory/theme-init.pl" if (!defined(&theme_settings));
# Global state for wrapper
# if 0, wrapper isn't on, add one and open it, if 1 close it, if 2+, subtract
# but don't close
@@ -131,10 +135,17 @@ else {
}
# theme_post_save_domain(&domain, action)
# Called by Virtualmin after a domain is updated, to refresh the left menu
# Called by Virtualmin after a domain is updated, to refresh the left menu.
# In the single page layout, a new domain becomes the one the menu shows.
sub theme_post_save_domain
{
local ($d, $action) = @_;
if (&theme_single_page()) {
# The next page builds the menu again, showing a new domain
print &theme_menu_remember({ 'dom' => $d->{'id'} })
if ($action eq 'create');
return;
}
# Refresh left side, in case options have changed
print "<script>\n";
if ($action eq 'create') {
@@ -152,6 +163,8 @@ print "</script>\n";
# Called after multiple domains are updated, to refresh the left menu
sub theme_post_save_domains
{
# The single page layout builds the menu again on the next page
return if (&theme_single_page());
print "<script>\n";
print "top.left.location = top.left.location;\n";
print "</script>\n";
@@ -162,8 +175,12 @@ print "</script>\n";
sub theme_post_save_server
{
local ($s, $action) = @_;
# The single page layout builds the menu again on the next page
return if (&theme_single_page());
if ($action eq 'create' || $action eq 'delete' ||
!$done_theme_post_save_server++) {
# Refresh the left frame after a system is added or removed, and once
# per page otherwise
print "<script>\n";
print "top.left.location = top.left.location;\n";
print "</script>\n";
@@ -171,11 +188,18 @@ if ($action eq 'create' || $action eq 'delete' ||
}
# theme_select_server(&server)
# Called by Cloudmin when a page for a server is displayed, to select it on the
# left menu.
# Called by Cloudmin when a page for a server is displayed, to select it on
# the left menu. In the single page layout, the menu shows it from the next
# page on.
sub theme_select_server
{
local ($server) = @_;
if (&theme_single_page()) {
# This page's menu is already built, so only remember the system for
# the next pages
print &theme_menu_remember({ 'sid' => $server->{'id'} });
return;
}
print <<EOF;
<script>
if (window.parent && window.parent.frames[0]) {
@@ -198,11 +222,18 @@ EOF
}
# theme_select_domain(&domain)
# Called by Virtualmin when a page for a server is displayed, to select it on
# the left menu.
# Called by Virtualmin when a page for a domain is displayed, to select it
# on the left menu. In the single page layout, the menu shows it from the
# next page on.
sub theme_select_domain
{
local ($d) = @_;
if (&theme_single_page()) {
# This page's menu is already built, so only remember the domain for
# the next pages
print &theme_menu_remember({ 'dom' => $d->{'id'} });
return;
}
print <<EOF;
<script>
if (window.parent && window.parent.frames[0]) {
@@ -227,6 +258,8 @@ EOF
sub theme_post_save_folder
{
local ($folder, $action) = @_;
# The single page layout builds the menu again on the next page
return if (&theme_single_page());
my $ref;
if ($action eq 'create' || $action eq 'delete' || $action eq 'modify') {
# Always refresh
@@ -240,17 +273,24 @@ else {
}
}
if ($ref) {
# Reload the left frame
print "<script>\n";
print "top.frames[0].document.location = top.frames[0].document.location;\n";
print "</script>\n";
}
}
# theme_post_change_modules()
# Called after modules are installed, removed or refreshed, to reload the
# left frame when it lists the Webmin modules
sub theme_post_change_modules
{
# The single page layout builds the menu again on the next page
return if (&theme_single_page());
print <<EOF;
<script>
var url = '' + top.left.location;
// Only the menu of the Webmin modules lists them
if (url.indexOf('mode=modules') > 0) {
top.left.location = url;
}
@@ -260,20 +300,31 @@ EOF
# theme_prebody(header-args...)
# Hides the module index link on Virtualmin pages, whose menu is already
# in the left frame
# shown beside the page, and prints the menu of the single page layout
sub theme_prebody
{
if (get_module_name() eq "virtual-server") {
# No need for Module Index link, as we have the left-side frame
# No need for Module Index link, as the menu already shows Virtualmin
$tconfig{'nomoduleindex'} = 1;
}
# Print the menu if theme_prehead chose to show it on this page
print &theme_page_menu() if ($main::gray_theme_page_menu);
}
# theme_popup_prebody(popup-header-args...)
# Prints the menu of the single page layout on a theme page that asks for
# it, such as the system information page. Real popups never get it.
sub theme_popup_prebody
{
print &theme_page_menu() if ($main::gray_theme_page_menu);
}
# theme_prehead()
# Prints the head parts of every page: the color scheme meta, the scheme
# and frame scripts, and the links to the stylesheet and the table sorting
# script. Also sets the body attributes the stylesheet keys on: the module
# name as a class and the forced color scheme, if any.
# name as a class and the forced color scheme, if any. In the single page
# layout it also decides whether the page gets the menu.
sub theme_prehead
{
my $pfx = &get_webprefix();
@@ -286,10 +337,205 @@ print "<link rel='stylesheet' type='text/css' href='$pfx/unauthenticated/".
print "<script type='text/javascript' src='$pfx/unauthenticated/".
"sorttable.js?".&theme_asset_key("sorttable.js")."'></script>\n";
my @cls = grep { $_ } ( $main::gray_theme_body_class, &get_module_name() );
# Decide whether this page shows the menu of the single page layout. The
# body hooks read the result.
my $menu = &theme_page_menu_wanted();
if ($menu && &theme_one_module()) {
# Webmin opens this user's only module directly, so a menu would
# list just that module. Skip it, and let the core header show its
# logout link, which the theme otherwise hides with the index link.
$menu = 0;
$tconfig{'noindex'} = 0;
}
$main::gray_theme_page_menu = $menu;
if ($menu) {
# Leave room for the menu, as wide as the left frame. A page that
# still loads in a frame, such as in the old frameset right after the
# layout changes, hides the menu.
push(@cls, 'single-page');
print "<style>:root { --menu-width: ".&theme_menu_width()."px; }".
"</style>\n";
# A window that a script opened as a popup, with no menu bar of its
# own, such as a chooser, hides the menu too
print "<script type='text/javascript'>if (self != top || ".
"window.opener && window.menubar && !window.menubar.visible) ".
"document.documentElement.classList.add('menu-framed');".
"</script>\n";
# Phones lay the page out at their own width, so the menu folds into
# its bar there
print "<meta name='viewport' ".
"content='width=device-width, initial-scale=1'>\n";
# The product's icons, as the frameset page has them
print &theme_favicons();
}
$tconfig{'inbody'} = (@cls ? "class='".join(" ", @cls)."'" : "").
($scheme ? " data-scheme='$scheme'" : "");
}
# theme_page_menu_wanted()
# Returns 1 if this page should show the menu of the single page layout:
# the layout is on, a user is logged in, and the page is not a popup. Pages
# loaded into a frame, an iframe or by a script get no menu either; browsers
# report this in the Sec-Fetch-Dest header. If the menu code cannot be
# loaded, the page shows no menu instead of an empty space.
sub theme_page_menu_wanted
{
return 0 if (!&theme_single_page());
return 0 if ($main::gray_theme_popup);
return 0 if (!$remote_user || $ENV{'ANONYMOUS_USER'});
my $dest = lc($ENV{'HTTP_SEC_FETCH_DEST'});
return 0 if ($dest && $dest ne 'document');
return &theme_menu_lib();
}
# theme_one_module()
# Returns 1 if Webmin is set to open a user's only module directly, the
# user has exactly one module, and it has no menu of its own. The modules
# are counted the way the core header counts them when it decides to show
# its logout link, hidden ones included, so the page never loses both the
# menu and that link. A user with a hidden module as well keeps the menu,
# which has its own logout link.
sub theme_one_module
{
return 0 if (!$gconfig{'gotoone'});
my @avail = &get_available_module_infos(1);
return 0 if (@avail != 1);
my $dir = &module_root_directory($avail[0]->{'dir'});
return -r "$dir/webmin_menu.pl" ? 0 : 1;
}
# theme_page_menu()
# Returns the menu of the single page layout. theme_prehead has already
# decided to show it and loaded the menu code. A page that switched to a
# Unix user other than root gets a panel that loads its menu instead.
sub theme_page_menu
{
return $> ? &theme_menu_placeholder() : &theme_menu_html({ 'single' => 1 });
}
# theme_menu_lib()
# Loads the functions that build the menu, once. Returns 1 when they are
# available.
sub theme_menu_lib
{
if (!defined(&theme_menu_html)) {
# do loads it into the package of this copy of theme.pl: WebminCore
# when the core calls the theme's hooks, as on module pages and the
# system information page, and main when a theme CGI such as left.cgi
# or menu.cgi calls it directly
do "$theme_root_directory/menu-lib.pl";
}
return defined(&theme_menu_html) ? 1 : 0;
}
# theme_single_page()
# Returns 1 if the theme settings choose the single page layout, with the
# menu on every page instead of in a frame of its own. A server opened
# through Webmin Servers Index keeps the frameset, as that index rewrites
# only the links and redirects of framed pages.
sub theme_single_page
{
return 0 if ($ENV{'HTTP_WEBMIN_SERVERS'} || $ENV{'HTTP_WEBMIN_PATH'});
return &theme_settings()->{'layout'} eq 'single' ? 1 : 0;
}
# theme_favicons()
# Returns the links to the icons of the product, Cloudmin, Virtualmin,
# Usermin or Webmin, in three sizes
sub theme_favicons
{
my $prod = &foreign_available("server-manager") ? 'cloudmin' :
&foreign_available("virtual-server") ? 'virtualmin' :
&get_product_name() eq 'usermin' ? 'usermin' : 'webmin';
my $dir = &get_webprefix()."/images/favicons/$prod";
return join("", map { "<link rel='icon' type='image/png' sizes='${_}x$_' ".
"href='$dir/favicon-${_}x$_.png'>\n" } (16, 32, 192));
}
# theme_menu_width()
# Returns the menu width in pixels: the width of the left frame, or of the
# space the menu takes in the single page layout
sub theme_menu_width
{
my $fsize = &theme_settings()->{'fsize'};
return $fsize =~ /^(\d+)$/ ? $1 :
&get_product_name() eq 'usermin' ? 200 :
&foreign_available("server-manager") &&
&foreign_available("virtual-server") ? 280 : 260;
}
# theme_menu_cookie()
# Returns the name of the cookie that remembers the menu choices of the
# single page layout, and the path it is set for. The name holds the port,
# so that Webmin and Usermin on one host keep their own choices.
sub theme_menu_cookie
{
my $port = $ENV{'SERVER_PORT'} =~ /^(\d+)$/ ? $1 : "";
# Use the URL prefix as the path, or / when there is none or it has
# characters that are unsafe in a cookie
my $path = &get_webprefix();
$path = "/" if ($path !~ /^\/[\w\/.~\-]*$/);
return ("gray_theme_menu$port", $path);
}
# theme_menu_state()
# Returns the menu choices of the single page layout as a hash ref: the
# menu mode, the Virtualmin domain ID and the Cloudmin system ID. They
# come from the cookie, or from a later change on this page.
sub theme_menu_state
{
return $main::gray_theme_menu_state if ($main::gray_theme_menu_state);
my ($name) = &theme_menu_cookie();
my %state;
if ($ENV{'HTTP_COOKIE'} =~ /(?:^|;\s*)\Q$name\E=([^;\s]*)/) {
# The value holds key=value pairs joined with &, all URL-encoded
foreach my $kv (split(/&/, &un_urlize($1))) {
my ($k, $v) = split(/=/, $kv, 2);
$state{$k} = $v if ($k =~ /^(mode|dom|sid)$/ &&
$v =~ /^[\w.\-]+$/);
}
}
$main::gray_theme_menu_state = \%state;
return $main::gray_theme_menu_state;
}
# theme_menu_cookie_string(&state)
# Returns the cookie that stores the menu choices, with its attributes, in
# the form both a Set-Cookie header and document.cookie take. It lasts
# until the browser closes.
sub theme_menu_cookie_string
{
my ($state) = @_;
my ($name, $path) = &theme_menu_cookie();
# Only the known choices, with values that need no quoting
my $value = join("&", map { "$_=$state->{$_}" }
grep { $state->{$_} =~ /^[\w.\-]+$/ }
('mode', 'dom', 'sid'));
return "$name=".&urlize($value)."; path=$path; SameSite=Lax".
(uc($ENV{'HTTPS'}) eq 'ON' ? "; Secure" : "");
}
# theme_menu_remember(&choices)
# Returns a script that makes menu choices, such as a domain ID, the ones
# of this page, and stores them in the cookie of the single page layout
# when it does not hold them yet. The menu script puts the page's choices
# back into the cookie whenever its tab comes back into use, so that tabs
# keep their own choices.
sub theme_menu_remember
{
my ($choices) = @_;
my $state = &theme_menu_state();
my %new = ( %$state, %$choices );
my $cookie = &theme_menu_cookie_string(\%new);
my $changed = $cookie ne &theme_menu_cookie_string($state);
$main::gray_theme_menu_state = \%new;
return "<script type='text/javascript'>".
"window.grayThemeMenuCookie = '$cookie';".
($changed ? " document.cookie = '$cookie';" : "").
"</script>\n";
}
# theme_scheme_script()
# Returns a script that keeps the color scheme steady when none is forced.
# A browser can report the system scheme for a moment before settling on
@@ -355,18 +601,8 @@ EOF
# system information page settings, per user unless they are global.
sub theme_color_scheme
{
return $main::gray_theme_scheme if (defined($main::gray_theme_scheme));
my $file = "$config_directory/$current_theme/sections";
my %sects;
&read_file($file, \%sects);
if (!$sects{'global'}) {
# Unless the global settings are forced on everyone, the user's own
# file replaces them
my %usects;
%sects = %usects if (&read_file("$file.$remote_user", \%usects));
}
$main::gray_theme_scheme = $sects{'scheme'} =~ /^(light|dark)$/ ? $1 : "";
return $main::gray_theme_scheme;
my $scheme = &theme_settings()->{'scheme'};
return $scheme =~ /^(light|dark)$/ ? $1 : "";
}
# theme_asset_key(file)
@@ -391,10 +627,19 @@ return "$tags class='$class'";
}
# theme_popup_prehead(title, ...)
# Popup windows and the frames get the same head as ordinary pages
# Popup windows and frames get the same head as ordinary pages. A theme
# page that uses the popup header but asks for the menu, such as the system
# information page, is not treated as a popup.
sub theme_popup_prehead
{
return &theme_prehead();
local $main::gray_theme_popup = !$main::gray_theme_menu_page;
&theme_prehead();
if ($main::gray_theme_page_menu &&
$current_lang_info->{'dir'} =~ /^(rtl|ltr)$/) {
# The popup header sets no text direction on the body, but the menu
# needs one to stay on the same side as on other pages
$tconfig{'inbody'} .= " dir='$1'";
}
}
# theme_ui_table_start(heading, [tabletags], [cols], [&default-tds],
@@ -1038,6 +1283,25 @@ if (get_module_name() eq "virtual-server" && $orig eq "" &&
print "Location: $url\n\n";
}
# theme_local_redirect(path)
# Prints a redirect to a path on this server, such as /right.cgi, with the
# path alone in the Location header. The browser then stays on the host and
# port it used. The core redirect builds a full URL from the redirect host
# in the Webmin settings, which a browser using another address may not
# reach, so the page hangs.
sub theme_local_redirect
{
my ($path) = @_;
if ($gconfig{'webprefixnoredir'}) {
# Webmin is set not to add the URL prefix to redirects, because a
# proxy rewrites the full URLs of its redirects; the core redirect
# gives it those
&redirect($path);
return;
}
print "Location: ".&get_webprefix()."$path\n\n";
}
# theme_ui_hidden_javascript()
# Returns the script that switches tabs and opens hidden sections. Both
# only change classes; the stylesheet does the rest.

View File

@@ -406,7 +406,8 @@ details.section > summary::after {
details.menu-cat > summary::after {
width: 5px;
height: 5px;
margin: 0 5px 1px 0;
margin-block: 0 1px;
margin-inline: 0 5px;
}
details[open].ui_hidden_table_start > summary::before,
details[open].section > summary::after,
@@ -651,11 +652,12 @@ table.ui_columns td > .ui_links_row { margin: 0; }
/* ---- Module page header ---- */
/* The header table printed by header() becomes the page title with the
* module links beside it, on the page ground. It bleeds over the page
* padding and stays at the top while the page scrolls. */
* padding and stays at the top while the page scrolls, or under the menu
* bar of the single page layout on a narrow screen. */
table.header {
position: sticky;
top: 0;
top: var(--sticky-top, 0);
z-index: 5;
display: block;
width: auto;
@@ -1710,8 +1712,8 @@ html.loading-right .menu-loader { display: block; }
.mode.loading { pointer-events: none; }
.mode.loading > b:not(.active) { opacity: 0.5; }
.mode.loading > b.active { color: var(--text-color); }
/* The ring sits 6px left of the name. Its right margin and its negative
* left margin cancel its own width, so it takes no room in the centered
/* The ring sits 6px before the name. Its end margin and its negative
* start margin cancel its own width, so it takes no room in the centered
* row and the name does not move when it appears */
.mode.loading > b.active > a::before {
content: "";
@@ -1719,7 +1721,8 @@ html.loading-right .menu-loader { display: block; }
box-sizing: border-box;
width: 10px;
height: 10px;
margin: 0 6px 0 -16px;
margin-block: 0;
margin-inline: -16px 6px;
border: 2px solid var(--border-color-darker);
border-top-color: var(--accent-color);
border-radius: 50%;
@@ -1773,7 +1776,8 @@ html.loading-right .menu-loader { display: block; }
/* Menu rows: 26px links that highlight under the mouse */
.menu-link {
display: block;
padding: 4px 8px 4px 12px;
padding-block: 4px;
padding-inline: 12px 8px;
border-radius: var(--radius-sm);
line-height: 18px;
color: var(--text-color);
@@ -1785,7 +1789,7 @@ html.loading-right .menu-loader { display: block; }
text-decoration: none;
}
/* Links inside a category are indented under its name */
.menu-link.menu-sub { padding-left: 24px; }
.menu-link.menu-sub { padding-inline-start: 24px; }
/* The logout link stands a little apart, muted, with its icon before
* the text like any other icon in a link */
.menu-link.menu-logout {
@@ -1793,7 +1797,7 @@ html.loading-right .menu-loader { display: block; }
color: var(--text-color-muted);
}
.menu-link svg {
margin-right: 6px;
margin-inline-end: 6px;
vertical-align: -3px;
opacity: 0.85;
}
@@ -1815,7 +1819,8 @@ details.menu-cat > summary {
justify-content: space-between;
gap: 8px;
height: 28px;
padding: 0 10px 0 12px;
padding-block: 0;
padding-inline: 12px 10px;
border-radius: var(--radius-sm);
color: var(--text-color);
list-style: none;
@@ -1842,6 +1847,134 @@ details.menu-cat[open] { margin-bottom: 4px; }
color: var(--text-color-muted);
}
.menu-html img { max-width: 100%; }
/* The link to the current page, marked in the single page layout */
.menu-link.active,
.menu-link.active:hover {
background: var(--element-highlight-bg-color);
color: var(--text-color);
font-weight: 600;
}
/* ---- Single page layout ---- */
/* Without frames, every page carries the menu panel at the start of its
* body. The panel is fixed at the left with the same 7px gap as in
* the left frame, and the body leaves as much room for it as the left
* frame would take, so a page looks just as it does in the frameset. The
* page and the menu scroll separately. The logical properties put the
* menu at the right for right-to-left languages. */
body.single-page {
padding-inline-start: calc(var(--menu-width, 260px) + var(--page-pad-x));
}
/* The system information page pads its own content */
body.right-frame.single-page {
padding-inline-start: var(--menu-width, 260px);
}
.page-menu {
position: fixed;
z-index: 20;
top: 7px;
bottom: 7px;
inset-inline-start: 7px;
width: calc(var(--menu-width, 260px) - 7px);
height: auto;
}
/* The button that opens the menu on a narrow screen, square and with an
* icon only; it shows a cross while the menu is open */
.page-menu .menu-toggle {
display: none;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 34px;
min-height: 0;
height: 34px;
margin: 0;
padding: 0;
}
.page-menu .menu-toggle svg { display: block; }
.page-menu .menu-toggle .menu-toggle-close,
.page-menu.open .menu-toggle .menu-toggle-open { display: none; }
.page-menu.open .menu-toggle .menu-toggle-close { display: block; }
/* A page that still ends up in a frame, such as in the old frameset right
* after the layout was changed, hides its menu */
html.menu-framed .page-menu { display: none; }
html.menu-framed body.single-page {
--menu-bar-height: 0px;
padding-inline-start: var(--page-pad-x);
}
html.menu-framed body.right-frame.single-page { padding-inline-start: 0; }
/* On a narrow screen the menu is a bar across the top with the logo and
* the button, and the pages start under it; their sticky headers stop
* under it too. The button unfolds the bar into the whole menu, which
* covers the screen and scrolls, while the page behind it stays put. */
@media (max-width: 800px) {
body.single-page {
--menu-bar-height: 52px;
--sticky-top: var(--menu-bar-height);
padding-top: calc(var(--menu-bar-height) + var(--page-pad-top));
padding-inline-start: var(--page-pad-x);
}
body.right-frame.single-page {
padding-top: var(--menu-bar-height);
padding-inline-start: 0;
}
body.single-page:has(> .page-menu.open) { overflow: hidden; }
/* The bar has no rounded corners, and a hairline and a soft shadow
* along its bottom edge instead of a border, so that it is exactly
* as tall as its brand row */
.page-menu {
top: 0;
bottom: auto;
inset-inline: 0;
width: auto;
max-height: 100vh;
max-height: 100dvh;
overflow: hidden;
border: 0;
border-radius: 0;
box-shadow: 0 1px 0 var(--border-color-panel),
0 2px 6px var(--shadow-color);
}
.page-menu.open {
bottom: 0;
overflow-y: auto;
}
/* The brand row: the logo at the start, the button at the end, and
* the host and login lines under them while the menu is open */
.page-menu .menu-brand {
display: flex;
flex-wrap: wrap;
align-items: center;
column-gap: 12px;
box-sizing: border-box;
min-height: var(--menu-bar-height);
padding-block: 0;
padding-inline: 16px 10px;
border-bottom: 0;
}
.page-menu .menu-brand-name {
flex: 1 1 auto;
margin: 0;
}
.page-menu .menu-brand-logo { height: 28px; }
.page-menu .menu-toggle { display: inline-flex; }
.page-menu .menu-meta {
flex: 1 0 100%;
padding-bottom: 14px;
}
.page-menu.open .menu-brand {
border-bottom: 1px solid var(--border-color-frame);
}
/* The folded bar shows the brand row alone */
.page-menu:not(.open) :is(.menu-meta, .mode, .leftmenu) {
display: none;
}
.page-menu:not(.open) .menu-top {
padding-bottom: 0;
border-bottom: 0;
}
}
/* ---- System information page (right.cgi) ---- */
/* The page is a widget page: its header holds the title, the operating
@@ -1858,11 +1991,12 @@ body.right-frame { padding: 0; }
}
.right-frame .ui_page.page > * { margin: 0; }
.right-frame .ui_page.page > * + * { margin-top: var(--ui-gap); }
/* The header bleeds over the page padding; its selector outranks the
/* The header bleeds over the page padding and stays at the top, or under
* the menu bar of the single page layout; its selector outranks the
* margin reset above */
.right-frame .ui_page.page > .ui_page_head {
position: sticky;
top: 0;
top: var(--sticky-top, 0);
z-index: 5;
align-items: center;
margin: 0 calc(-1 * var(--page-pad-x)) 12px;
@@ -2319,14 +2453,15 @@ form.ui_form[action="uconfig.cgi"] {
* scroll. The module sizes the terminal from the whole viewport with no
* room for the header, so the page scrolled, and the scrollbar widened
* the terminal past the frame. The 66px are the sticky header and its
* margin; the rules outrank the module's own by specificity. */
* margin, plus the menu bar of the single page layout on a narrow screen;
* the rules outrank the module's own by specificity. */
body.xterm { overflow: hidden; }
body.xterm #terminal {
box-sizing: border-box;
width: auto;
min-width: 0;
max-width: none;
height: calc(100vh - 66px - var(--card-gap));
height: calc(100vh - 66px - var(--card-gap) - var(--menu-bar-height, 0px));
min-height: 0;
max-height: none;
margin: 0;
@@ -2510,6 +2645,11 @@ form[action="config_save.cgi"] #sortableTable2 > thead > tr > td {
@media print {
body { background: #fff; color: #000; }
.page-menu { display: none; }
body.single-page, body.right-frame.single-page {
padding-top: 0;
padding-inline-start: 0;
}
body > .header,
body > .header ~ center,
.ui_form ~ a,