Skip to content

Commit 2186baf

Browse files
committed
feat: Improve rendering of topbar and login screen
1 parent a5ee3ec commit 2186baf

5 files changed

Lines changed: 266 additions & 31 deletions

File tree

js/login_responsive.js

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* Responsive Login Page - Vanilla JavaScript
3+
* Handles login form functionality without framework dependencies
4+
*
5+
* Copyright 2026 Horde LLC (http://www.horde.org/)
6+
*
7+
* See the enclosed file LICENSE for license information (LGPL-2). If you
8+
* did not receive this file, see https://www.horde.org/licenses/lgpl.
9+
*/
10+
11+
document.addEventListener('DOMContentLoaded', function() {
12+
// Get translated strings from global scope (set by login.php)
13+
var strings = window.HordeLoginStrings || {
14+
username: "Please enter a username.",
15+
password: "Please enter a password.",
16+
capsLock: "Caps Lock is on"
17+
};
18+
19+
// Show mode selector if it exists
20+
var modeDiv = document.getElementById('horde_select_view_div');
21+
var modeSelect = document.getElementById('horde_select_view');
22+
if (modeDiv && modeSelect) {
23+
// Remove mobile_nojs option (requires JavaScript)
24+
var noJsOption = modeSelect.querySelector('option[value="mobile_nojs"]');
25+
if (noJsOption) {
26+
noJsOption.remove();
27+
}
28+
29+
// Set selected value from cookie or default
30+
var preSelected = window.HordeLoginPreSelected || "auto";
31+
if (preSelected) {
32+
var option = modeSelect.querySelector('option[value="' + preSelected + '"]');
33+
if (option) {
34+
modeSelect.selectedIndex = option.index;
35+
}
36+
}
37+
38+
// Show the mode selector
39+
modeDiv.style.display = '';
40+
}
41+
42+
// Capture hash for anchor navigation
43+
if (location.hash) {
44+
var anchorInput = document.getElementById('anchor_string');
45+
if (anchorInput) {
46+
anchorInput.value = location.hash.substring(1);
47+
}
48+
}
49+
50+
// Focus appropriate field
51+
var userField = document.getElementById('horde_user');
52+
var passField = document.getElementById('horde_pass');
53+
var loginButton = document.getElementById('login-button');
54+
55+
if (userField && !userField.value) {
56+
userField.focus();
57+
} else if (passField && !passField.value) {
58+
passField.focus();
59+
} else if (loginButton) {
60+
loginButton.focus();
61+
}
62+
63+
// Handle form submission
64+
var form = document.getElementById('horde_login');
65+
if (form) {
66+
form.addEventListener('submit', function(e) {
67+
var loginPost = document.getElementById('login_post');
68+
if (loginPost) {
69+
loginPost.value = '1';
70+
}
71+
72+
// Validate fields
73+
if (userField && !userField.value) {
74+
e.preventDefault();
75+
alert(strings.username);
76+
userField.focus();
77+
return false;
78+
}
79+
if (passField && !passField.value) {
80+
e.preventDefault();
81+
alert(strings.password);
82+
passField.focus();
83+
return false;
84+
}
85+
86+
// Disable button to prevent double-submit
87+
if (loginButton) {
88+
loginButton.disabled = true;
89+
}
90+
});
91+
}
92+
93+
// Handle language change
94+
var langSelect = document.getElementById('new_lang');
95+
if (langSelect) {
96+
langSelect.addEventListener('change', function() {
97+
// Only reload if user hasn't entered credentials yet
98+
if ((!userField || !userField.value) && (!passField || !passField.value)) {
99+
window.location = 'login.php?new_lang=' + encodeURIComponent(this.value);
100+
}
101+
});
102+
}
103+
104+
// Caps lock detection for password field
105+
if (passField) {
106+
passField.addEventListener('keypress', function(e) {
107+
var capsWarning = document.getElementById('horde-login-pass-capslock');
108+
if (!capsWarning) {
109+
capsWarning = document.createElement('div');
110+
capsWarning.id = 'horde-login-pass-capslock';
111+
capsWarning.className = 'alert alert-warning';
112+
capsWarning.style.display = 'none';
113+
capsWarning.style.marginTop = '0.5rem';
114+
capsWarning.textContent = strings.capsLock;
115+
passField.parentNode.appendChild(capsWarning);
116+
}
117+
118+
var charCode = e.keyCode || e.which;
119+
var shiftKey = e.shiftKey;
120+
121+
// Check if caps lock is on
122+
if ((charCode >= 65 && charCode <= 90 && !shiftKey) ||
123+
(charCode >= 97 && charCode <= 122 && shiftKey)) {
124+
capsWarning.style.display = 'block';
125+
} else {
126+
capsWarning.style.display = 'none';
127+
}
128+
});
129+
}
130+
});

login.php

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,6 @@ function _addAnchor($url, $type, $vars, $url_anchor = null)
257257
// Build mode options based on configuration
258258
$modeOptions = [
259259
'auto' => ['name' => _("Automatic")],
260-
'disabled' => null,
261260
];
262261

263262
// Add Basic mode if enabled (default: disabled)
@@ -271,9 +270,12 @@ function _addAnchor($url, $type, $vars, $url_anchor = null)
271270
// Add Minimal mode if enabled (default: disabled)
272271
if (!empty($GLOBALS['conf']['user']['select_minimal_view'])) {
273272
$modeOptions['mobile'] = ['name' => _("Mobile (Minimal)")];
274-
$modeOptions['mobile_nojs'] = ['name' => _("Mobile (No JavaScript)")];
275273
}
276274

275+
// Always include mobile_nojs so JavaScript can remove it
276+
// (JavaScript expects this option to exist and removes it on load)
277+
$modeOptions['mobile_nojs'] = ['name' => _("Mobile (No JavaScript)")];
278+
277279
// Always include Smartmobile mode
278280
$modeOptions['smartmobile'] = ['name' => _("Mobile (Smartphone/Tablet)")];
279281

@@ -421,19 +423,8 @@ function _addAnchor($url, $type, $vars, $url_anchor = null)
421423
$theme = $responsiveAssets->getTheme();
422424
$cssUrls = $responsiveAssets->getCssUrls();
423425

424-
// Build JS URLs from $js_files array
425-
$jsUrls = [];
426-
// First add Prototype.js which login.js depends on
427-
$jsUrls[] = $jsUri . '/prototype.js';
428-
// Then add the login-specific files
429-
foreach ($js_files as $jsFile) {
430-
if (is_array($jsFile)) {
431-
list($file, $app) = $jsFile;
432-
$jsUrls[] = $registry->get('jsuri', $app) . '/' . $file;
433-
} else {
434-
$jsUrls[] = $jsUri . '/' . $jsFile;
435-
}
436-
}
426+
// Load responsive login JavaScript (vanilla JavaScript, no Prototype.js)
427+
$jsUrls = [$jsUri . '/login_responsive.js'];
437428

438429
// Build error HTML if reason exists
439430
$errorHtml = '';
@@ -593,10 +584,13 @@ function _addAnchor($url, $type, $vars, $url_anchor = null)
593584
</div>
594585

595586
<script>
596-
// Inline JS variables for login.js
597-
<?php foreach ($js_code as $key => $value): ?>
598-
<?php echo $key ?> = <?php echo json_encode($value, JSON_HEX_TAG | JSON_HEX_AMP) ?>;
599-
<?php endforeach; ?>
587+
// Pass data to external JavaScript
588+
window.HordeLoginPreSelected = <?php echo json_encode($vars->get('horde_select_view', $_COOKIE['default_horde_view'] ?? 'auto'), JSON_HEX_TAG | JSON_HEX_AMP) ?>;
589+
window.HordeLoginStrings = {
590+
username: <?php echo json_encode(_("Please enter a username."), JSON_HEX_TAG | JSON_HEX_AMP) ?>,
591+
password: <?php echo json_encode(_("Please enter a password."), JSON_HEX_TAG | JSON_HEX_AMP) ?>,
592+
capsLock: <?php echo json_encode(_("Caps Lock is on"), JSON_HEX_TAG | JSON_HEX_AMP) ?>
593+
};
600594
</script>
601595
<?php foreach ($jsUrls as $jsUrl): ?>
602596
<script src="<?php echo $escape($jsUrl) ?>"></script>

src/Auth/ResponsiveLoginController.php

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,11 @@ private function showLoginForm(ServerRequestInterface $request): ResponseInterfa
160160
}
161161

162162
// Check if mode selector should be shown
163-
// Get conf from global or default to false
163+
// Get conf from global
164164
$conf = $GLOBALS['conf'] ?? [];
165-
$showModeSelector = !empty($conf['user']['select_view'] ?? false);
165+
166+
// Default to true if config isn't available (show selector by default)
167+
$showModeSelector = !isset($conf['user']) || !empty($conf['user']['select_view'] ?? true);
166168

167169
// Check if password reset is enabled
168170
$showPasswordReset = !empty($conf['auth']['resetpassword'] ?? false);
@@ -307,26 +309,26 @@ private function renderModeSelector($vars): string
307309
$conf = $GLOBALS['conf'] ?? [];
308310
$currentMode = $vars->get('horde_select_view', $_COOKIE['default_horde_view'] ?? 'auto');
309311

310-
// Start with automatic and core modes always available
312+
// Start with automatic mode
311313
$modes = [
312-
'auto' => 'Automatic',
314+
'auto' => _("Automatic"),
313315
];
314316

315-
// Add Basic mode if enabled (default: disabled)
317+
// Add Basic mode if explicitly enabled (default: disabled)
316318
if (!empty($conf['user']['select_basic_view'])) {
317-
$modes['basic'] = 'Basic';
319+
$modes['basic'] = _("Basic");
318320
}
319321

320322
// Always include Dynamic mode
321-
$modes['dynamic'] = 'Dynamic';
323+
$modes['dynamic'] = _("Dynamic");
322324

323-
// Add Minimal mode if enabled (default: disabled)
325+
// Add Minimal mode if explicitly enabled (default: disabled)
324326
if (!empty($conf['user']['select_minimal_view'])) {
325-
$modes['mobile'] = 'Minimal (Legacy Mobile)';
327+
$modes['mobile'] = _("Mobile (Minimal)");
326328
}
327329

328-
// Always include Smartmobile mode
329-
$modes['smartmobile'] = 'Mobile (Smartphone/Tablet)';
330+
// Always include Smartmobile/Responsive mode
331+
$modes['smartmobile'] = _("Mobile (Smartphone/Tablet)");
330332

331333
$options = '';
332334
foreach ($modes as $value => $name) {
@@ -337,7 +339,7 @@ private function renderModeSelector($vars): string
337339

338340
return <<<HTML
339341
<div class="form-group">
340-
<label for="horde_select_view" class="form-label">Mode</label>
342+
<label for="horde_select_view" class="form-label">{$this->escapeHtml(_("Mode"))}</label>
341343
<select id="horde_select_view" name="horde_select_view" class="form-input">
342344
{$options}
343345
</select>

templates/responsive/topbar.html.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
* - $portalUrl: string - URL to portal
99
* - $logoutUrl: string - URL to logout
1010
* - $userName: string - Logged in username
11+
* - $topLevelApps: array - Top-level apps (no menu_parent) for topbar
12+
* - $allApps: array - All apps for hamburger menu
1113
*
1214
* Copyright 2026 Horde LLC (http://www.horde.org/)
1315
*/
@@ -21,6 +23,19 @@
2123
</button>
2224

2325
<h1 class="topbar-title"><?= htmlspecialchars($appName) ?></h1>
26+
27+
<?php if (!empty($topLevelApps)): ?>
28+
<nav class="topbar-apps" aria-label="Applications">
29+
<?php foreach ($topLevelApps as $app): ?>
30+
<a href="<?= htmlspecialchars($app['url']) ?>"
31+
class="topbar-app-link topbar-app-<?= htmlspecialchars($app['app']) ?>"
32+
title="<?= htmlspecialchars($app['name']) ?>">
33+
<span class="app-icon"></span>
34+
<span class="app-name"><?= htmlspecialchars($app['name']) ?></span>
35+
</a>
36+
<?php endforeach; ?>
37+
</nav>
38+
<?php endif; ?>
2439
</header>
2540

2641
<nav class="topbar-menu" hidden data-topbar-menu aria-label="Main menu">
@@ -32,6 +47,18 @@
3247
<span class="link-text"><?= _("Portal") ?></span>
3348
</a>
3449

50+
<?php if (!empty($allApps)): ?>
51+
<div class="topbar-menu-section">
52+
<h2 class="topbar-menu-heading"><?= _("Applications") ?></h2>
53+
<?php foreach ($allApps as $app): ?>
54+
<a href="<?= htmlspecialchars($app['url']) ?>"
55+
class="topbar-menu-link topbar-menu-app-<?= htmlspecialchars($app['app']) ?>">
56+
<span class="link-text"><?= htmlspecialchars($app['name']) ?></span>
57+
</a>
58+
<?php endforeach; ?>
59+
</div>
60+
<?php endif; ?>
61+
3562
<a href="<?= htmlspecialchars($logoutUrl) ?>" class="topbar-menu-link topbar-logout">
3663
<span class="link-text"><?= _("Logout") ?></span>
3764
</a>

themes/default/responsive.css

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,53 @@ body {
642642
flex: 1;
643643
}
644644

645+
/* Top-level app links (desktop) */
646+
.topbar-apps {
647+
display: none;
648+
align-items: center;
649+
gap: var(--space-sm);
650+
margin-left: auto;
651+
}
652+
653+
.topbar-app-link {
654+
display: inline-flex;
655+
align-items: center;
656+
gap: var(--space-xs);
657+
padding: var(--space-sm) var(--space-md);
658+
color: var(--topbar-text);
659+
text-decoration: none;
660+
font-size: var(--font-size-sm);
661+
font-weight: var(--font-weight-medium);
662+
border-radius: var(--radius-sm);
663+
transition: background var(--transition-base);
664+
}
665+
666+
.topbar-app-link:link,
667+
.topbar-app-link:visited,
668+
.topbar-app-link:active {
669+
color: var(--topbar-text);
670+
}
671+
672+
.topbar-app-link:hover {
673+
background: var(--topbar-hover);
674+
color: var(--topbar-text);
675+
}
676+
677+
.topbar-app-link .app-icon {
678+
display: none;
679+
}
680+
681+
.topbar-app-link .app-name {
682+
white-space: nowrap;
683+
color: var(--topbar-text);
684+
}
685+
686+
@media (min-width: 768px) {
687+
.topbar-apps {
688+
display: flex;
689+
}
690+
}
691+
645692
/* Hamburger menu - mobile overlay */
646693
.topbar-menu {
647694
position: fixed;
@@ -721,6 +768,41 @@ body {
721768
content: '🚪'; /* U+1F6AA DOOR */
722769
}
723770

771+
/* App-specific icon overrides */
772+
/* Apps provide their own icon overrides in {app}/themes/default/responsive.css */
773+
/* Example for Turba:
774+
.topbar-menu-app-turba::before {
775+
content: '';
776+
display: inline-block;
777+
width: 20px;
778+
height: 20px;
779+
background-image: url('graphics/turba.png');
780+
background-size: contain;
781+
background-repeat: no-repeat;
782+
background-position: center;
783+
vertical-align: middle;
784+
}
785+
*/
786+
787+
/* Default app icon (package emoji) for apps without custom icons */
788+
.topbar-menu-link[class*="topbar-menu-app-"]::before {
789+
content: '📦';
790+
}
791+
792+
.topbar-menu-section {
793+
border-bottom: 1px solid var(--border-light);
794+
}
795+
796+
.topbar-menu-heading {
797+
padding: var(--space-md) var(--space-lg);
798+
font-size: var(--font-size-sm);
799+
font-weight: var(--font-weight-semibold);
800+
color: var(--text-secondary);
801+
text-transform: uppercase;
802+
letter-spacing: 0.05em;
803+
margin: 0;
804+
}
805+
724806
.topbar-menu-link .link-text {
725807
font-size: var(--font-size-base);
726808
}

0 commit comments

Comments
 (0)