mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-11-05 18:22:21 +01:00
Android's WebView scales text by the system font-size setting; Chrome
ignores it and uses its own accessibility preference. With the system
font one step above default, the wrapper rendered every string ~20%
larger than the website at an identical layout width -- the brand
truncated to "mc-web...", message bodies wrapped early, and the footer
broke apart. Pin textZoom to 100 so the wrapper matches the browser.
The footer was fragile independently of that. Status, region badge and
the "Updated:" time sat in a justify-content-between row with no wrap,
so once they exceeded the width each item broke internally instead of
the row reflowing -- a dot with "Polaczono" stranded below it, a time
split from its own label. Longer translated labels ("Aktualizacja:" vs
"Updated:") hit this well before English did. Adding flex-wrap plus
text-nowrap on the items makes them reflow as whole units; .badge
already carries white-space: nowrap, so it needs nothing.
Verified in Chrome against the local container, pl locale, region badge
forced visible, at 412/360/320 CSS px and with a 1.2x text-only override
that reproduces textZoom: before/after screenshots show the reported
breakage and a clean two-line reflow respectively, with no horizontal
overflow. At full width the change is a no-op.
Rides along in the already-pending, never-built wrapper 1.2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
404 lines
24 KiB
HTML
404 lines
24 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="{{ lang }}" data-theme="light" data-bs-theme="light">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
<title>{{ t('dm.title') }} - mc-webui</title>
|
|
|
|
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
|
|
<script>
|
|
(function() {
|
|
var theme = localStorage.getItem('mc-webui-theme') || 'light';
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
document.documentElement.setAttribute('data-bs-theme', theme);
|
|
})();
|
|
</script>
|
|
|
|
<!-- Sidebar breakpoint: apply, react to resize, and react to changes from parent (Settings -> Interface) -->
|
|
<script>
|
|
(function() {
|
|
function readBp() {
|
|
try {
|
|
var s = parseInt(localStorage.getItem('mc-webui-sidebar-breakpoint'), 10);
|
|
return (isNaN(s) || s < 600 || s > 2000) ? 992 : s;
|
|
} catch (e) { return 992; }
|
|
}
|
|
function apply() {
|
|
document.documentElement.classList.toggle('layout-wide', window.innerWidth >= readBp());
|
|
}
|
|
apply();
|
|
var raf = null;
|
|
window.addEventListener('resize', function() {
|
|
if (raf) return;
|
|
raf = requestAnimationFrame(function() { raf = null; apply(); });
|
|
});
|
|
// Live update when the user changes the breakpoint in the parent window's Settings
|
|
window.addEventListener('storage', function(e) {
|
|
if (e.key === 'mc-webui-sidebar-breakpoint' || e.key === null) apply();
|
|
});
|
|
})();
|
|
</script>
|
|
|
|
{% include "_head_i18n.html" %}
|
|
|
|
<!-- Favicon -->
|
|
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
|
|
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
|
|
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='images/favicon-16x16.png') }}">
|
|
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
|
|
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
|
|
|
<!-- Bootstrap 5 CSS (local) -->
|
|
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
|
|
<!-- Bootstrap Icons (local) -->
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/bootstrap-icons.css') }}">
|
|
|
|
<!-- Leaflet CSS (for repeater map picker) -->
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
|
crossorigin="" />
|
|
|
|
<!-- Custom CSS -->
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
|
<!-- Theme CSS (light/dark mode) -->
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
|
|
|
|
<!-- Emoji Picker (local) -->
|
|
<script type="module" src="{{ url_for('static', filename='vendor/emoji-picker-element/index.js') }}"></script>
|
|
<!-- Inline styles removed - now in style.css -->
|
|
</head>
|
|
<body>
|
|
<!-- Main Content -->
|
|
<main>
|
|
<div class="container-fluid d-flex flex-column" style="height: 100vh;">
|
|
<!-- Main content: sidebar + chat -->
|
|
<div class="d-flex flex-grow-1 overflow-hidden" style="min-height: 0;">
|
|
<!-- DM Sidebar (visible on lg+ screens) -->
|
|
<div id="dmSidebar" class="dm-sidebar">
|
|
<div class="dm-sidebar-header">
|
|
<input type="text"
|
|
id="dmSidebarSearch"
|
|
class="form-control form-control-sm"
|
|
placeholder="{{ t('dm.sidebar.search_ph') }}"
|
|
autocomplete="off">
|
|
</div>
|
|
<div class="dm-sidebar-list" id="dmSidebarList">
|
|
<!-- Populated by JavaScript -->
|
|
</div>
|
|
</div>
|
|
<!-- Chat Area -->
|
|
<div class="flex-grow-1 d-flex flex-column" style="min-width: 0;">
|
|
<!-- Conversation Selector Bar (mobile only, hidden on lg+) -->
|
|
<div class="dm-mobile-selector border-bottom bg-light">
|
|
<div class="p-2">
|
|
<div class="d-flex align-items-center gap-2">
|
|
<!-- Searchable contact selector -->
|
|
<div class="position-relative flex-grow-1" id="dmContactSearchWrapper">
|
|
<input type="text"
|
|
id="dmContactSearchInput"
|
|
class="form-control"
|
|
placeholder="{{ t('dm.select_chat_ph') }}"
|
|
autocomplete="off">
|
|
<div id="dmContactDropdown" class="dm-contact-dropdown" style="display: none;"></div>
|
|
</div>
|
|
<!-- Clear search button -->
|
|
<button type="button"
|
|
class="btn btn-outline-secondary flex-shrink-0"
|
|
id="dmClearSearchBtn"
|
|
title="{{ t('dm.clear_selection_title') }}"
|
|
style="display: none;">
|
|
<i class="bi bi-x-lg"></i>
|
|
</button>
|
|
<!-- Contact info button -->
|
|
<button type="button"
|
|
class="btn btn-outline-secondary flex-shrink-0"
|
|
id="dmContactInfoBtn"
|
|
title="{{ t('dm.contact_info_title') }}"
|
|
disabled>
|
|
<i class="bi bi-info-circle"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Desktop contact header (visible on lg+ when sidebar is shown) -->
|
|
<div class="dm-desktop-header border-bottom bg-light">
|
|
<div class="p-2 d-flex align-items-center gap-2">
|
|
<span id="dmDesktopContactName" class="fw-medium flex-grow-1 text-truncate"></span>
|
|
<button type="button"
|
|
class="btn btn-outline-secondary btn-sm flex-shrink-0"
|
|
id="dmDesktopInfoBtn"
|
|
title="{{ t('dm.contact_info_title') }}"
|
|
disabled>
|
|
<i class="bi bi-info-circle"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<!-- Messages Container -->
|
|
<div class="flex-grow-1 position-relative overflow-hidden" style="min-height: 0;">
|
|
<!-- Filter bar overlay -->
|
|
<div id="dmFilterBar" class="filter-bar">
|
|
<div class="filter-bar-inner">
|
|
<input type="text" id="dmFilterInput" class="filter-bar-input" placeholder="{{ t('chat.filter.ph') }}" autocomplete="off">
|
|
<span id="dmFilterMatchCount" class="filter-match-count"></span>
|
|
<button type="button" id="dmFilterClearBtn" class="filter-bar-btn filter-bar-btn-clear" title="{{ t('chat.filter.clear_title') }}">
|
|
<i class="bi bi-x"></i>
|
|
</button>
|
|
<button type="button" id="dmFilterCloseBtn" class="filter-bar-btn filter-bar-btn-close" title="{{ t('common.close') }}">
|
|
<i class="bi bi-x-lg"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div id="dmMessagesContainer" class="messages-container h-100 overflow-auto p-3">
|
|
<div id="dmMessagesList">
|
|
<!-- Placeholder shown when no conversation selected -->
|
|
<div class="dm-empty-state">
|
|
<i class="bi bi-envelope"></i>
|
|
<p class="mb-1">{{ t('dm.empty.select') }}</p>
|
|
<small class="text-muted">{{ t('dm.empty.select_hint') }}</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Scroll to bottom button -->
|
|
<button id="dmScrollToBottomBtn" class="scroll-to-bottom-btn" title="{{ t('chat.scroll_bottom_title') }}">
|
|
<i class="bi bi-chevron-double-down"></i>
|
|
</button>
|
|
</div>
|
|
<!-- Send Message Form -->
|
|
<div class="border-top bg-light">
|
|
<form id="dmSendForm" class="p-3">
|
|
<div class="emoji-picker-container">
|
|
<div class="input-group">
|
|
<textarea
|
|
id="dmMessageInput"
|
|
class="form-control"
|
|
placeholder="{{ t('chat.input_ph') }}"
|
|
rows="2"
|
|
maxlength="500"
|
|
disabled
|
|
></textarea>
|
|
<button type="button" class="btn btn-outline-secondary" id="dmEmojiBtn" title="{{ t('chat.emoji_title') }}">
|
|
<i class="bi bi-emoji-smile"></i>
|
|
</button>
|
|
<button type="submit" class="btn btn-success px-4" id="dmSendBtn" disabled>
|
|
<i class="bi bi-send"></i>
|
|
</button>
|
|
</div>
|
|
<!-- Emoji picker popup (hidden by default) -->
|
|
<div id="dmEmojiPickerPopup" class="emoji-picker-popup hidden"></div>
|
|
</div>
|
|
<div class="d-flex justify-content-end">
|
|
<small class="text-muted"><span id="dmCharCounter">0</span> / 150</small>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<!-- Status Bar -->
|
|
<div class="border-top">
|
|
<div class="p-2 small text-muted d-flex flex-wrap gap-2 justify-content-between align-items-center">
|
|
<span id="dmStatusText" class="text-nowrap">
|
|
<i class="bi bi-circle-fill text-secondary"></i> {{ t('common.connecting') }}
|
|
</span>
|
|
<span id="dmLastRefresh" class="text-nowrap">{{ t('chat.updated', time=t('common.never')) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Floating Action Buttons -->
|
|
<div class="fab-container" id="dmFabContainer">
|
|
<button class="fab fab-toggle" id="dmFabToggle" title="{{ t('chat.fab.hide') }}">
|
|
<i class="bi bi-chevron-right"></i>
|
|
</button>
|
|
<button class="fab fab-filter" id="dmFilterFab" title="{{ t('chat.filter.fab_title') }}">
|
|
<i class="bi bi-funnel-fill"></i>
|
|
</button>
|
|
<button class="fab fab-settings" id="dmSettingsFab" title="{{ t('common.settings') }}">
|
|
<i class="bi bi-gear-fill"></i>
|
|
</button>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Contact Info Modal -->
|
|
<div class="modal fade" id="dmContactInfoModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h6 class="modal-title"><i class="bi bi-person-circle"></i> {{ t('dm.contact_info') }}</h6>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body" id="dmContactInfoBody"></div>
|
|
<!-- Path management section (populated dynamically) -->
|
|
<div class="modal-body border-top pt-2 pb-1" id="dmPathSection" style="display: none;">
|
|
<div class="path-section-header">
|
|
<h6><i class="bi bi-signpost-split"></i> {{ t('repeaters.paths.title') }}</h6>
|
|
<button type="button" class="btn btn-outline-primary btn-sm" id="dmAddPathBtn" title="{{ t('repeaters.paths.add_title') }}">
|
|
<i class="bi bi-plus-lg"></i>
|
|
</button>
|
|
</div>
|
|
<div id="dmPathList"></div>
|
|
<!-- Path action buttons -->
|
|
<div class="d-flex justify-content-end gap-2 mt-1">
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" id="dmClearPathsBtn"
|
|
title="{{ t('repeaters.paths.clear_title') }}">
|
|
<i class="bi bi-trash"></i> {{ t('repeaters.paths.clear') }}
|
|
</button>
|
|
<button type="button" class="btn btn-outline-danger btn-sm" id="dmResetFloodBtn"
|
|
title="{{ t('repeaters.paths.reset_flood_title') }}">
|
|
<i class="bi bi-broadcast"></i> {{ t('repeaters.paths.reset_flood') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<div class="d-flex align-items-center justify-content-between w-100">
|
|
<div class="d-flex gap-3">
|
|
<div class="form-check form-switch" title="{{ t('dm.auto_retry_title') }}">
|
|
<input class="form-check-input" type="checkbox" id="dmAutoRetryToggle" checked>
|
|
<label class="form-check-label small" for="dmAutoRetryToggle">{{ t('dm.auto_retry') }}</label>
|
|
</div>
|
|
<div class="form-check form-switch" title="{{ t('dm.keep_path_title') }}">
|
|
<input class="form-check-input" type="checkbox" id="dmNoAutoFloodToggle">
|
|
<label class="form-check-label small" for="dmNoAutoFloodToggle">{{ t('dm.keep_path') }}</label>
|
|
</div>
|
|
</div>
|
|
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{{ t('common.close') }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Repeater Map Picker Modal -->
|
|
<div class="modal fade" id="repeaterMapModal" tabindex="-1" style="z-index: 1080;">
|
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header py-2">
|
|
<h6 class="modal-title"><i class="bi bi-geo-alt"></i> {{ t('repeaters.map.title') }}</h6>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body p-0">
|
|
<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom bg-light">
|
|
<div class="form-check form-switch mb-0">
|
|
<input class="form-check-input" type="checkbox" id="rptMapCachedSwitch">
|
|
<label class="form-check-label small" for="rptMapCachedSwitch">{{ t('repeaters.map.cached') }}</label>
|
|
</div>
|
|
<span class="text-muted small ms-auto" id="rptMapCount"></span>
|
|
</div>
|
|
<div id="rptLeafletMap" style="height: 400px; width: 100%;"></div>
|
|
</div>
|
|
<div class="modal-footer py-2">
|
|
<span class="me-auto small text-muted" id="rptMapSelected">{{ t('repeaters.map.hint') }}</span>
|
|
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{{ t('common.close') }}</button>
|
|
<button type="button" class="btn btn-sm btn-primary" id="rptMapAddBtn" disabled>{{ t('common.add') }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add Path Modal -->
|
|
<div class="modal fade" id="addPathModal" tabindex="-1" style="z-index: 1070;">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header py-2">
|
|
<h6 class="modal-title"><i class="bi bi-signpost-split"></i> {{ t('repeaters.addpath.title') }}</h6>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body" id="dmAddPathForm">
|
|
<div class="mb-2">
|
|
<label class="form-label small mb-1">{{ t('repeaters.addpath.hash_size') }}</label>
|
|
<div class="btn-group btn-group-sm w-100" role="group">
|
|
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash1" value="1" checked>
|
|
<label class="btn btn-outline-secondary" for="pathHash1">{{ t('repeaters.addpath.hash_1b') }}</label>
|
|
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash2" value="2">
|
|
<label class="btn btn-outline-secondary" for="pathHash2">{{ t('repeaters.addpath.hash_2b') }}</label>
|
|
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash3" value="3">
|
|
<label class="btn btn-outline-secondary" for="pathHash3">{{ t('repeaters.addpath.hash_3b') }}</label>
|
|
</div>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label small mb-1">{{ t('repeaters.addpath.path_hex') }}</label>
|
|
<div class="input-group input-group-sm">
|
|
<input type="text" class="form-control font-monospace" id="dmPathHexInput"
|
|
placeholder="{{ t('repeaters.addpath.path_hex_ph') }}" autocomplete="off">
|
|
<button type="button" class="btn btn-outline-secondary" id="dmPickRepeaterBtn"
|
|
title="{{ t('repeaters.addpath.pick_list_title') }}">
|
|
<i class="bi bi-plus-circle"></i>
|
|
</button>
|
|
<button type="button" class="btn btn-outline-secondary" id="dmPickRepeaterMapBtn"
|
|
title="{{ t('repeaters.addpath.pick_map_title') }}">
|
|
<i class="bi bi-geo-alt"></i>
|
|
</button>
|
|
</div>
|
|
<div id="dmPathUniquenessWarning" class="path-uniqueness-warning mt-1" style="display: none;"></div>
|
|
</div>
|
|
<!-- Repeater picker (hidden by default) -->
|
|
<div id="dmRepeaterPicker" style="display: none;" class="border rounded mb-2">
|
|
<div class="d-flex border-bottom">
|
|
<div class="btn-group btn-group-sm flex-shrink-0" role="group">
|
|
<input type="radio" class="btn-check" name="repeaterSearchMode" id="rptSearchName" value="name" checked>
|
|
<label class="btn btn-outline-secondary border-0 rounded-0" for="rptSearchName">{{ t('repeaters.addpath.by_name') }}</label>
|
|
<input type="radio" class="btn-check" name="repeaterSearchMode" id="rptSearchId" value="id">
|
|
<label class="btn btn-outline-secondary border-0 rounded-0" for="rptSearchId">{{ t('repeaters.addpath.by_id') }}</label>
|
|
</div>
|
|
<input type="text" class="form-control form-control-sm border-0"
|
|
id="dmRepeaterSearch" placeholder="{{ t('repeaters.addpath.search_ph') }}" autocomplete="off">
|
|
</div>
|
|
<div id="dmRepeaterList" style="max-height: 180px; overflow-y: auto;"></div>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label small mb-1">{{ t('repeaters.addpath.label') }}</label>
|
|
<input type="text" class="form-control form-control-sm" id="dmPathLabelInput"
|
|
placeholder="{{ t('repeaters.addpath.label_ph') }}" maxlength="50">
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer py-2">
|
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="dmCancelPathBtn" data-bs-dismiss="modal">{{ t('common.cancel') }}</button>
|
|
<button type="button" class="btn btn-sm btn-primary" id="dmSavePathBtn">{{ t('repeaters.addpath.title') }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toast container for notifications (position classes applied by JS from ui_settings) -->
|
|
<div class="toast-container position-fixed top-0 start-0 p-3" data-toast-container>
|
|
<div id="notificationToast" class="toast" role="alert">
|
|
<div class="toast-header">
|
|
<strong class="me-auto">mc-webui</strong>
|
|
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
|
</div>
|
|
<div class="toast-body"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bootstrap 5 JS Bundle (local) -->
|
|
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
|
|
|
|
<!-- Message Content Processing Utilities (must load before dm.js) -->
|
|
<script src="{{ url_for('static', filename='js/message-utils.js') }}"></script>
|
|
|
|
<!-- Filter Utilities (must load before dm.js) -->
|
|
<script src="{{ url_for('static', filename='js/filter-utils.js') }}"></script>
|
|
|
|
<!-- Leaflet JS (for repeater map picker) -->
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
|
crossorigin=""></script>
|
|
|
|
<!-- SocketIO for real-time updates -->
|
|
<script src="{{ url_for('static', filename='vendor/socket.io/socket.io.min.js') }}"></script>
|
|
|
|
<!-- FAB Utilities (drag, sizing) -->
|
|
<script src="{{ url_for('static', filename='js/fab-utils.js') }}"></script>
|
|
|
|
<!-- Custom JS -->
|
|
<script src="{{ url_for('static', filename='js/dm.js') }}"></script>
|
|
|
|
<script>
|
|
// Pass configuration from Flask to JavaScript
|
|
window.MC_CONFIG = {
|
|
deviceName: "{{ device_name }}",
|
|
initialConversation: "{{ initial_conversation or '' }}"
|
|
};
|
|
</script>
|
|
</body>
|
|
</html>
|