mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
Feat(mobile): scroll-hide nav, iOS PWA safe-area painter, theme refactor
- Nav slides up on scroll-down / down on scroll-up on mobile, with threshold + top-of-page protection - iOS PWA: apple-mobile-web-app-status-bar-style=black-translucent + fixed-position safe-area painter in Nav, driven by --page-bg CSS var. Works around WebKit's refusal to honor JS theme-color updates and media-variant theme-color tags in standalone PWA mode. - Sheet + Drawer primitives now respect safe-area-inset via inline padding (top/left/right get inset-top; bottom gets inset-bottom) - Extract theme orchestration from Preferences.vue into use-theme.js composable mounted at App root — Preferences is now pure UI - Align page-bg color literals across style.css (--page-bg), index.html (html bg + meta theme-color), and manifest.webmanifest Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
405b1ef04a
commit
1752576374
9 files changed
+153
-73
No files matched your search
@@ -60,6 +60,7 @@ import { useInfoMask } from '@/composables/use-info-mask.js';
|
||||
import { useRefreshOrchestrator } from '@/composables/use-refresh-orchestrator.js';
|
||||
import { useShortcuts } from '@/composables/use-shortcuts.js';
|
||||
import { useSectionTracking } from '@/composables/use-section-tracking.js';
|
||||
import { useTheme } from '@/composables/use-theme.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useMainStore();
|
||||
@@ -137,6 +138,9 @@ const { loadShortcuts } = useShortcuts({
|
||||
// Scroll monitoring + section tracking (logic from widgets/Patch.vue)
|
||||
useSectionTracking();
|
||||
|
||||
// Theme orchestration: initial apply, OS flip listener, preference watcher.
|
||||
useTheme();
|
||||
|
||||
onMounted(() => {
|
||||
loadingControl();
|
||||
loadShortcuts();
|
||||
|
||||
+68
-10
@@ -1,7 +1,18 @@
|
||||
<template>
|
||||
<!-- iOS PWA safe-area painter. Pairs with apple-mobile-web-app-status-bar-style=black-translucent
|
||||
in index.html — the only way to get a live status-bar tint on iOS PWA, since WebKit
|
||||
ignores JS theme-color writes and media-variant theme-color tags in standalone mode.
|
||||
Color tracks --page-bg (style.css), which follows .dark class. -->
|
||||
<div
|
||||
class="fixed top-0 left-0 right-0 z-50 pointer-events-none transition-colors duration-300"
|
||||
style="height: env(safe-area-inset-top); background: var(--page-bg);"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<!-- Nav -->
|
||||
<header
|
||||
class="sticky top-0 z-40 w-full border-b bg-background/80 supports-[backdrop-filter:blur(0px)]:bg-background/60 backdrop-blur">
|
||||
class="sticky top-[env(safe-area-inset-top)] z-40 w-full border-b bg-background/80 supports-[backdrop-filter:blur(0px)]:bg-background/60 backdrop-blur transition-transform duration-300 ease-out will-change-transform"
|
||||
:class="{ '-translate-y-full': isNavHidden }">
|
||||
<nav id="navbar-top"
|
||||
class="mx-auto flex w-full max-w-[1600px] items-center gap-2 px-3 sm:px-4 h-14">
|
||||
|
||||
@@ -170,7 +181,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useMainStore } from '@/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { trackEvent } from '@/utils/use-analytics';
|
||||
@@ -196,13 +207,11 @@ import { SECTION_IDS } from '@/data/sections';
|
||||
const { t } = useI18n();
|
||||
const store = useMainStore();
|
||||
|
||||
// Basic state
|
||||
const isDarkMode = computed(() => store.isDarkMode);
|
||||
const isMobile = computed(() => store.isMobile);
|
||||
const currentSection = computed(() => store.currentSection);
|
||||
const loaded = ref(false);
|
||||
|
||||
// Navigation items (desktop + mobile share the same list)
|
||||
const navItems = SECTION_IDS;
|
||||
|
||||
// nav link style — current section highlight use bg-accent instead of only bold
|
||||
@@ -247,17 +256,14 @@ const onNavMenuChange = (val) => {
|
||||
store.setOpenSheet(val ? 'navMenu' : null);
|
||||
};
|
||||
|
||||
// Open preferences — consumed by `p` key in use-shortcuts.js defineExpose
|
||||
// Consumed by `p` shortcut in use-shortcuts.js via defineExpose.
|
||||
const OpenPreferences = () => {
|
||||
store.toggleSheet('preferences');
|
||||
trackEvent('Nav', 'NavClick', 'Preferences');
|
||||
};
|
||||
|
||||
// Logo click:
|
||||
// - Page middle → smooth scroll to top
|
||||
// - Already at top → trigger full refresh
|
||||
// Note: native behavior of <a href="#"> is to jump to top instantly, not smooth scrolling.
|
||||
// Here we prevent the default behavior and use window.scrollTo + smooth to ensure the animation effect.
|
||||
// At top → full refresh; mid-page → smooth scroll up. preventDefault
|
||||
// avoids the native instant-jump of <a href="#">.
|
||||
const handleLogoClick = (e) => {
|
||||
if (window.scrollY === 0) {
|
||||
store.setRefreshEveryThing(true);
|
||||
@@ -278,6 +284,58 @@ const scrollToSection = (el, offset = 70) => {
|
||||
|
||||
watch(() => store.allHasLoaded, (newValue) => { loaded.value = newValue; });
|
||||
|
||||
// Mobile: hide nav on scroll-down, show on scroll-up.
|
||||
// SCROLL_DELTA filters out micro-jitter; SHOW_AT_TOP forces the nav
|
||||
// visible near the top of the page regardless of direction.
|
||||
const isNavHidden = ref(false);
|
||||
let lastScrollY = 0;
|
||||
let scrollTicking = false;
|
||||
const SCROLL_DELTA = 5;
|
||||
const SHOW_AT_TOP = 48;
|
||||
|
||||
const onScroll = () => {
|
||||
if (scrollTicking) return;
|
||||
scrollTicking = true;
|
||||
requestAnimationFrame(() => {
|
||||
const y = window.scrollY;
|
||||
const dy = y - lastScrollY;
|
||||
if (y <= SHOW_AT_TOP) {
|
||||
isNavHidden.value = false;
|
||||
} else if (Math.abs(dy) > SCROLL_DELTA) {
|
||||
// Keep nav visible while the menu drawer is open so its close
|
||||
// affordance stays in place.
|
||||
if (dy > 0 && !isNavMenuOpen.value) {
|
||||
isNavHidden.value = true;
|
||||
} else if (dy < 0) {
|
||||
isNavHidden.value = false;
|
||||
}
|
||||
}
|
||||
lastScrollY = y;
|
||||
scrollTicking = false;
|
||||
});
|
||||
};
|
||||
|
||||
watch(isMobile, (mobile) => {
|
||||
if (!mobile) {
|
||||
isNavHidden.value = false;
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
} else {
|
||||
lastScrollY = window.scrollY;
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
}
|
||||
}, { immediate: false });
|
||||
|
||||
onMounted(() => {
|
||||
if (isMobile.value) {
|
||||
lastScrollY = window.scrollY;
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
});
|
||||
|
||||
defineExpose({ OpenPreferences });
|
||||
</script>
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ defineEmits(['close', 'escapeKeyDown']);
|
||||
'fixed inset-x-0 bottom-0 z-[10001] mt-24 flex h-auto flex-col rounded-t-[14px] border bg-background shadow-lg outline-none',
|
||||
props.class,
|
||||
)"
|
||||
:style="{ paddingBottom: 'env(safe-area-inset-bottom)' }"
|
||||
@escape-key-down="$emit('escapeKeyDown', $event)"
|
||||
>
|
||||
<DrawerHandle
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
// shadcn-vue Sheet 面板:四向滑入,包含 overlay
|
||||
// 见 refactor/01 阶段 B
|
||||
import { computed } from 'vue';
|
||||
import { DialogContent, DialogOverlay, DialogPortal, DialogTitle, VisuallyHidden } from 'reka-ui';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -30,6 +31,25 @@ const props = defineProps({
|
||||
onInteractOutside: { type: Function, default: undefined },
|
||||
});
|
||||
defineEmits(['close', 'escapeKeyDown']);
|
||||
|
||||
// Push content past the iOS safe-area insets so it doesn't render
|
||||
// behind the transparent status bar / home indicator. Inline style
|
||||
// so consumers' `p-0` doesn't override it.
|
||||
const safeAreaStyle = computed(() => {
|
||||
switch (props.side) {
|
||||
case 'top':
|
||||
return { paddingTop: 'env(safe-area-inset-top)' };
|
||||
case 'bottom':
|
||||
return { paddingBottom: 'env(safe-area-inset-bottom)' };
|
||||
case 'left':
|
||||
case 'right':
|
||||
default:
|
||||
return {
|
||||
paddingTop: 'env(safe-area-inset-top)',
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -39,6 +59,7 @@ defineEmits(['close', 'escapeKeyDown']);
|
||||
/>
|
||||
<DialogContent
|
||||
:class="cn(sheetVariants({ side }), props.class)"
|
||||
:style="safeAreaStyle"
|
||||
:on-pointer-down-outside="onPointerDownOutside"
|
||||
:on-interact-outside="onInteractOutside"
|
||||
@escape-key-down="$emit('escapeKeyDown', $event)"
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch, h } from 'vue';
|
||||
import { computed, onMounted, h } from 'vue';
|
||||
import { useMainStore } from '@/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { trackEvent } from '@/utils/use-analytics';
|
||||
@@ -148,7 +148,6 @@ import {
|
||||
const { t } = useI18n();
|
||||
|
||||
const store = useMainStore();
|
||||
const isDarkMode = computed(() => store.isDarkMode);
|
||||
const configs = computed(() => store.configs);
|
||||
const userPreferences = computed(() => store.userPreferences);
|
||||
const ipDBs = computed(() => store.ipDBs);
|
||||
@@ -183,45 +182,6 @@ const currentIpDB = computed(() =>
|
||||
ipDBs.value.find(db => db.id === userPreferences.value.ipGeoSource)
|
||||
);
|
||||
|
||||
// Theme coordination — applies the user's preference ("light" / "dark" / "auto")
|
||||
// to the store, <html> `.dark` class, body class, and PWA meta colors.
|
||||
//
|
||||
// Single source of truth: applyTheme() reads the *current* userPreferences.theme
|
||||
// and the *current* OS color-scheme together, decides the effective dark state,
|
||||
// and pushes it everywhere. Called on mount, on OS flip, and on user preference
|
||||
// change — so all three triggers path through the same logic.
|
||||
//
|
||||
// The earlier version read userPreferences.theme at callback-definition time
|
||||
// and also called handleThemeChange inside prefTheme *before* persisting the
|
||||
// new theme value, so "auto" could miss the OS flip and "click auto" could
|
||||
// get stuck on the previous manual state.
|
||||
const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const applyTheme = () => {
|
||||
const theme = userPreferences.value.theme;
|
||||
const isDark =
|
||||
theme === 'dark' ||
|
||||
(theme === 'auto' && mediaQueryList.matches);
|
||||
store.setDarkMode(isDark);
|
||||
updateBodyClass();
|
||||
PWAColor();
|
||||
};
|
||||
|
||||
const handleMediaChange = () => applyTheme();
|
||||
|
||||
const updateBodyClass = () => {
|
||||
document.body.classList.toggle('body-dark-mode', isDarkMode.value);
|
||||
};
|
||||
|
||||
const PWAColor = () => {
|
||||
const themeColor = document.querySelector('meta[name="theme-color"]');
|
||||
const backgroundColor = document.querySelector('meta[name="background-color"]');
|
||||
const color = isDarkMode.value ? '#171a1d' : '#f8f9fa';
|
||||
const bgColor = isDarkMode.value ? '#212529' : '#ffffff';
|
||||
themeColor.setAttribute('content', color);
|
||||
backgroundColor.setAttribute('content', bgColor);
|
||||
};
|
||||
|
||||
const updateIPDBs = () => {
|
||||
if (configs.value && Object.keys(configs.value).length > 0) {
|
||||
store.updateIPDBs({ id: 0, enabled: configs.value.ipChecking });
|
||||
@@ -232,9 +192,8 @@ const updateIPDBs = () => {
|
||||
};
|
||||
|
||||
const prefTheme = (value) => {
|
||||
// Persist first so applyTheme() reads the new value, then apply.
|
||||
// Application is handled by use-theme.js, which watches this preference.
|
||||
store.updatePreference('theme', value);
|
||||
applyTheme();
|
||||
trackEvent('Nav', 'PreferenceClick', 'Theme');
|
||||
};
|
||||
|
||||
@@ -281,21 +240,9 @@ const prefipGeoSource = (value) => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
mediaQueryList.addEventListener('change', handleMediaChange);
|
||||
applyTheme();
|
||||
setTimeout(updateIPDBs, 4000);
|
||||
});
|
||||
|
||||
// Clean up the OS listener if this component is ever torn down (it normally
|
||||
// lives for the life of the app, but be hygienic).
|
||||
onUnmounted(() => {
|
||||
mediaQueryList.removeEventListener('change', handleMediaChange);
|
||||
});
|
||||
|
||||
// Also react when the user switches theme via any path — the watcher keeps
|
||||
// applyTheme() firing even if a future code path mutates store.theme directly.
|
||||
watch(() => userPreferences.value.theme, applyTheme);
|
||||
|
||||
|
||||
// Section title: lucide icon + text, unified rhythm
|
||||
const SectionTitle = (props, { slots }) =>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Theme orchestrator. Reads userPreferences.theme + OS color-scheme,
|
||||
// resolves the effective dark state, and pushes it to the store and
|
||||
// body class. Reacts to OS theme flips and preference changes. Mount
|
||||
// once at the app root (App.vue) — multiple instances would attach
|
||||
// duplicate listeners.
|
||||
|
||||
import { onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useMainStore } from '@/store';
|
||||
|
||||
export function useTheme() {
|
||||
const store = useMainStore();
|
||||
const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const applyTheme = () => {
|
||||
const theme = store.userPreferences.theme;
|
||||
const isDark =
|
||||
theme === 'dark' ||
|
||||
(theme === 'auto' && mediaQueryList.matches);
|
||||
store.setDarkMode(isDark);
|
||||
document.body.classList.toggle('body-dark-mode', isDark);
|
||||
};
|
||||
|
||||
const handleMediaChange = () => applyTheme();
|
||||
|
||||
onMounted(() => {
|
||||
mediaQueryList.addEventListener('change', handleMediaChange);
|
||||
applyTheme();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
mediaQueryList.removeEventListener('change', handleMediaChange);
|
||||
});
|
||||
|
||||
watch(() => store.userPreferences.theme, applyTheme);
|
||||
}
|
||||
@@ -67,6 +67,11 @@
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--radius: 0.5rem;
|
||||
/* Canonical page background. Single source of truth for the iOS PWA
|
||||
safe-area painter (Nav.vue). Must stay in sync with the literal
|
||||
hex values in: index.html inline `html { background }`, index.html
|
||||
<meta theme-color>, public/manifest.webmanifest. */
|
||||
--page-bg: #ffffff;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -98,6 +103,7 @@
|
||||
--border: oklch(1 0 0 / 0.1);
|
||||
--input: oklch(1 0 0 / 0.15);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--page-bg: #0e0e0e;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
|
||||
+15
-7
@@ -10,7 +10,9 @@
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="author" content="Jason Ng" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="theme-color" content="#f8f9fa" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0e0e0e" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="background-color" content="#ffffff" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<!-- Avoid auto-linking phone numbers -->
|
||||
@@ -98,6 +100,12 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Keep page content below the iOS safe-area (transparent status
|
||||
bar). env() resolves to 0 on non-notch platforms — no effect. */
|
||||
body {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
html {
|
||||
line-height: 1.5;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
@@ -108,7 +116,7 @@
|
||||
"Apple Color Emoji", "Segoe UI Emoji";
|
||||
scrollbar-gutter: stable;
|
||||
background: #ffffff;
|
||||
color: #0a0a0a;
|
||||
color: #0e0e0e;
|
||||
}
|
||||
|
||||
/* Dark mode is gated strictly on the `.dark` class — *never* on
|
||||
@@ -118,7 +126,7 @@
|
||||
before first paint, so boot-screen rendering already reflects the
|
||||
right theme without a flash. */
|
||||
html.dark {
|
||||
background: #0a0a0a;
|
||||
background: #0e0e0e;
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
@@ -143,13 +151,13 @@
|
||||
}
|
||||
|
||||
html.dark .jn-loading {
|
||||
background: #0a0a0a;
|
||||
background: #0e0e0e;
|
||||
}
|
||||
|
||||
/* Hand-off, driven from App.vue:
|
||||
stage-1 — non-logo children fade out, logo stays put.
|
||||
stage-2 — logo scales down to 0 and fades.
|
||||
stage-3 — overlay removed, `#app` runs `.jn-app-enter` below. */
|
||||
stage-1 — non-logo children fade out, logo stays put.
|
||||
stage-2 — logo scales down to 0 and fades.
|
||||
stage-3 — overlay removed, `#app` runs `.jn-app-enter` below. */
|
||||
.jn-loading>svg,
|
||||
.jn-loading>div,
|
||||
.jn-loading>.jn-spinner,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#f8f9fa",
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"orientation": "portrait",
|
||||
"icons": [
|
||||
|
||||
Reference in new issue
Block a user