Add responsive mobile layouts and navigation
This commit is contained in:
+66
-13
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onNavigate, goto } from '$app/navigation';
|
||||
import { afterNavigate, onNavigate, goto } from '$app/navigation';
|
||||
import { startPipelineStream } from '$ui/stores/pipeline.js';
|
||||
import { startServerStream } from '$ui/stores/servers.js';
|
||||
import { page } from '$app/stores';
|
||||
@@ -13,6 +13,17 @@
|
||||
|
||||
interface Props { children: import('svelte').Snippet }
|
||||
let { children }: Props = $props();
|
||||
let menuOpen = $state(false);
|
||||
let menuButton: HTMLButtonElement;
|
||||
|
||||
afterNavigate(() => { menuOpen = false; });
|
||||
|
||||
function closeMenu(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && menuOpen) {
|
||||
menuOpen = false;
|
||||
menuButton?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
startPipelineStream();
|
||||
@@ -64,26 +75,32 @@
|
||||
{ href: '/scripts', label: 'Scripts', match: (p: string) => p.startsWith('/scripts') },
|
||||
{ href: '/configs', label: 'Configs', match: (p: string) => p.startsWith('/configs') },
|
||||
{ href: '/plugins', label: 'Plugins', match: (p: string) => p.startsWith('/plugins') },
|
||||
{ href: '/deployments', label: 'Deployments', match: (p: string) => p === '/deployments' },
|
||||
{ href: '/deployments', label: 'Deployments', match: (p: string) => p.startsWith('/deployments') },
|
||||
{ href: '/campaigns', label: 'Campaigns', match: (p: string) => p.startsWith('/campaigns') },
|
||||
{ href: '/server-keys', label: 'Keys', match: (p: string) => p.startsWith('/server-keys') },
|
||||
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') }
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={closeMenu} />
|
||||
|
||||
<div class="shell">
|
||||
<nav aria-label="Primary">
|
||||
<a class="brand-link" href="/" aria-label="Electric Cherry home">
|
||||
<BrandMark size="sm" />
|
||||
</a>
|
||||
<div class="tabs" role="tablist">
|
||||
<button class="menu-toggle" bind:this={menuButton} type="button"
|
||||
aria-expanded={menuOpen} aria-controls="primary-links"
|
||||
onclick={() => { menuOpen = !menuOpen; }}>
|
||||
{menuOpen ? 'Close' : 'Menu'}
|
||||
<span aria-hidden="true">{menuOpen ? '×' : '☰'}</span>
|
||||
</button>
|
||||
<div class="tabs" id="primary-links" class:expanded={menuOpen}>
|
||||
{#each tabs as t (t.href)}
|
||||
{@const on = t.match($page.url.pathname)}
|
||||
<a
|
||||
href={t.href}
|
||||
class:on
|
||||
role="tab"
|
||||
aria-selected={on}
|
||||
aria-current={on ? 'page' : undefined}
|
||||
>
|
||||
<span class="lbl">{t.label}</span>
|
||||
@@ -159,6 +176,7 @@
|
||||
}
|
||||
|
||||
.brand-link {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
@@ -173,6 +191,7 @@
|
||||
margin-left: var(--space-4);
|
||||
}
|
||||
.tabs a {
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: none;
|
||||
@@ -196,25 +215,59 @@
|
||||
}
|
||||
|
||||
.strut { margin-left: auto; }
|
||||
.menu-toggle { display: none; }
|
||||
|
||||
main {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
padding: var(--space-10) var(--space-10) var(--space-24);
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
view-transition-name: main;
|
||||
}
|
||||
|
||||
/* Mobile — collapse nav to a bottom-anchored, thumb-reachable bar.
|
||||
(Fully realized in Phase 8; this is the graceful base.) */
|
||||
@media (max-width: 720px) {
|
||||
/* The full navigation needs more room than a phone or portrait tablet. */
|
||||
@media (max-width: 1000px) {
|
||||
nav {
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-4);
|
||||
overflow-x: auto;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 0;
|
||||
height: auto;
|
||||
min-height: 60px;
|
||||
padding: calc(8px + env(safe-area-inset-top)) max(var(--space-4), env(safe-area-inset-right)) 8px max(var(--space-4), env(safe-area-inset-left));
|
||||
max-height: 100dvh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.tabs { margin-left: var(--space-2); }
|
||||
main { padding: var(--space-6) var(--space-4) var(--space-16); }
|
||||
.menu-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 44px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.menu-toggle:focus-visible { box-shadow: var(--focus-ring); }
|
||||
.tabs { display: none; width: 100%; margin: 0; }
|
||||
.tabs.expanded { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); padding: var(--space-3) 0 4px; }
|
||||
.tabs a { display: flex; align-items: center; min-height: 44px; border-radius: var(--radius-md); }
|
||||
.strut { display: none; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
main { padding: var(--space-6) max(var(--space-4), env(safe-area-inset-right)) calc(var(--space-16) + env(safe-area-inset-bottom)) max(var(--space-4), env(safe-area-inset-left)); }
|
||||
/* Keep native controls readable and prevent focus zoom on phones. */
|
||||
:global(input:not([type='checkbox']):not([type='radio'])),
|
||||
:global(select), :global(textarea) { font-size: 16px !important; min-height: 44px; }
|
||||
:global(button), :global(.btn) { min-height: 44px; }
|
||||
}
|
||||
|
||||
:global(input), :global(select), :global(textarea) { min-width: 0; max-width: 100%; }
|
||||
:global(dialog) { max-height: calc(100dvh - 32px); overflow-y: auto; overscroll-behavior: contain; }
|
||||
|
||||
/* View-transition tuning — cross-fade routes, ease the shared nav pill. */
|
||||
:global(::view-transition-old(root)),
|
||||
|
||||
@@ -365,11 +365,11 @@
|
||||
.picks { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.pick-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
.pick-select { position: relative; }
|
||||
.pick-select { position: relative; min-width: 0; }
|
||||
.native-select {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
@@ -451,7 +451,7 @@
|
||||
.chk input { accent-color: var(--accent-primary); cursor: pointer; }
|
||||
|
||||
|
||||
.form-actions { display: flex; align-items: center; gap: var(--space-4); }
|
||||
.form-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-4); }
|
||||
.flash { color: var(--state-danger); font-family: var(--font-mono); font-size: var(--text-xs); }
|
||||
|
||||
.section { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
@@ -487,5 +487,9 @@
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.beta-row { grid-template-columns: 1fr; gap: var(--space-2); }
|
||||
.pick-row { grid-template-columns: minmax(0, 1fr) 44px; }
|
||||
.pick-select:first-child { grid-column: 1 / -1; }
|
||||
.chk { min-height: 44px; min-width: 0; }
|
||||
.chk input { flex-shrink: 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -376,4 +376,9 @@
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.srv-row { align-items: flex-start; flex-direction: column; }
|
||||
.srv-l, .file-row { flex-wrap: wrap; }
|
||||
.diff-modal { padding: var(--space-4); max-height: 92dvh; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -362,7 +362,7 @@
|
||||
.upload-form .full { grid-column: 1 / -1; }
|
||||
.scope-row { display: flex; gap: var(--space-2); flex-wrap: wrap; }
|
||||
.scope-row :global(.wrap) { flex: 1 1 140px; min-width: 140px; }
|
||||
.form-actions { grid-column: 1 / -1; display: flex; align-items: center; gap: var(--space-4); }
|
||||
.form-actions { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-4); }
|
||||
.flash { color: var(--state-danger); font-family: var(--font-mono); font-size: var(--text-xs); }
|
||||
|
||||
.section { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
|
||||
@@ -152,4 +152,7 @@
|
||||
.timeline li { position: relative; padding: 0 0 24px 12px; overflow-wrap: anywhere; }
|
||||
.timeline li::before { content: ''; position: absolute; left: -24px; top: 5px; width: 10px; height: 10px; border-radius: 50%; background: var(--accent-primary); }
|
||||
.timeline p { margin-top: 6px; } .error { color: var(--state-danger); } .warning { color: var(--state-warning); }
|
||||
@media (max-width: 640px) {
|
||||
.timing { flex-direction: column; gap: var(--space-2); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -249,7 +249,7 @@
|
||||
.upload-form { display: grid; gap: var(--space-4); grid-template-columns: 1fr 1fr; }
|
||||
.scope-row { display: flex; gap: var(--space-2); flex-wrap: wrap; }
|
||||
.scope-row :global(.wrap) { flex: 1 1 140px; min-width: 140px; }
|
||||
.form-actions { grid-column: 1 / -1; display: flex; align-items: center; gap: var(--space-4); }
|
||||
.form-actions { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-4); }
|
||||
.flash { color: var(--state-danger); font-family: var(--font-mono); font-size: var(--text-xs); }
|
||||
|
||||
.section { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
|
||||
@@ -316,6 +316,7 @@
|
||||
.preview.warn { background: rgba(255, 176, 32, 0.06); color: var(--state-warning); border: 1px solid rgba(255, 176, 32, 0.28); }
|
||||
|
||||
.form-actions {
|
||||
flex-wrap: wrap;
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -395,6 +395,8 @@
|
||||
|
||||
{#if netDiagnosis}
|
||||
{#if netDiagnosis.ok && netDiagnosis.results}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll the table.) -->
|
||||
<div class="table-scroll" role="region" aria-label="Network diagnostics" tabindex="0">
|
||||
<table class="net-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -421,6 +423,7 @@
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if netDiagnosis.config}
|
||||
<div class="net-meta">
|
||||
KOMODO_ADDRESS = <code>{netDiagnosis.config.komodoAddress ?? '(unset)'}</code>
|
||||
@@ -607,7 +610,7 @@
|
||||
/* Diagnostics KV grid */
|
||||
.diag-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
@@ -714,7 +717,10 @@
|
||||
}
|
||||
|
||||
/* Network table */
|
||||
.table-scroll { max-width: 100%; overflow-x: auto; }
|
||||
.table-scroll:focus-visible { box-shadow: var(--focus-ring); }
|
||||
.net-table {
|
||||
min-width: 600px;
|
||||
width: 100%;
|
||||
margin-top: var(--space-4);
|
||||
border-collapse: separate;
|
||||
@@ -810,7 +816,8 @@
|
||||
.hist-err { color: var(--state-danger); font-size: var(--text-xs); font-family: var(--font-mono); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.metrics { grid-template-columns: 1fr 1fr; }
|
||||
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.panel-head, .alert { flex-wrap: wrap; }
|
||||
.hist-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-1);
|
||||
|
||||
Reference in New Issue
Block a user