fix(mobile): improve app store search and launches
This commit is contained in:
parent
3e01e57c8d
commit
1836b035b4
@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.7.73-alpha (2026-05-19)
|
||||||
|
|
||||||
|
- Mobile app launches for iframe-blocked apps now open the direct app URL in a new browser tab immediately instead of landing in a broken in-shell webview that requires a second tap.
|
||||||
|
- Mobile My Apps/Websites tabs now react to route query changes, App Store pages label the mobile view as Discover, mobile filters have safe bottom spacing, and App Store search ignores the current category so searches cover all available apps.
|
||||||
|
- My Apps search now surfaces matching App Store entries when the app is not installed, making it possible to jump directly from a failed My Apps search to the installable app details.
|
||||||
|
- NetBird self-host installs now prefer a `100.x` tailnet/CGNAT address for dashboard, management, relay, STUN, and auth redirect origins when one is present; live repair on `100.89.209.89` updated the existing stack from LAN origins to `100.89.209.89` and restored `netbird-server`.
|
||||||
|
- App-session iframe frames now focus automatically and wrap the iframe in a scroll host so wheel/touch scrolling works in the active right frame without requiring an initial click.
|
||||||
|
|
||||||
## v1.7.72-alpha (2026-05-19)
|
## v1.7.72-alpha (2026-05-19)
|
||||||
|
|
||||||
- Settings What's New now includes the missing release notes for `v1.7.68-alpha` through `v1.7.71-alpha`, so the modal reflects the current OTA history instead of stopping at `v1.7.67-alpha`.
|
- Settings What's New now includes the missing release notes for `v1.7.68-alpha` through `v1.7.71-alpha`, so the modal reflects the current OTA history instead of stopping at `v1.7.67-alpha`.
|
||||||
|
|||||||
@ -1404,7 +1404,9 @@ impl RpcHandler {
|
|||||||
.await
|
.await
|
||||||
.context("Failed to create NetBird data directory")?;
|
.context("Failed to create NetBird data directory")?;
|
||||||
|
|
||||||
let host_ip = self.config.host_ip.clone();
|
let host_ip = detect_netbird_public_host_ip()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|| self.config.host_ip.clone());
|
||||||
let dashboard_origin = format!("http://{}:8087", host_ip);
|
let dashboard_origin = format!("http://{}:8087", host_ip);
|
||||||
let mgmt_origin = format!("http://{}:8086", host_ip);
|
let mgmt_origin = format!("http://{}:8086", host_ip);
|
||||||
let relay_secret = read_or_generate_b64_secret("netbird-relay-auth-secret").await;
|
let relay_secret = read_or_generate_b64_secret("netbird-relay-auth-secret").await;
|
||||||
@ -1544,6 +1546,19 @@ async fn read_or_generate_b64_secret(name: &str) -> String {
|
|||||||
secret
|
secret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn detect_netbird_public_host_ip() -> Option<String> {
|
||||||
|
let output = tokio::process::Command::new("hostname")
|
||||||
|
.args(["-I"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
stdout
|
||||||
|
.split_whitespace()
|
||||||
|
.find(|ip| ip.starts_with("100.") && ip.contains('.'))
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{btcpay_stack_app_ids, mempool_stack_app_ids};
|
use super::{btcpay_stack_app_ids, mempool_stack_app_ids};
|
||||||
|
|||||||
@ -1659,6 +1659,15 @@ html:has(body.video-background-active)::before {
|
|||||||
filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5));
|
filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-filter-btn {
|
||||||
|
bottom: calc(var(--mobile-tab-bar-height, 72px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 12px);
|
||||||
|
filter: drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-filter-sheet {
|
||||||
|
padding-bottom: calc(var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Cloud Audio Player (mini bar) ──── */
|
/* ── Cloud Audio Player (mini bar) ──── */
|
||||||
|
|
||||||
.cloud-audio-player {
|
.cloud-audio-player {
|
||||||
|
|||||||
@ -341,9 +341,8 @@ watch(displayMode, (mode) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Desktop apps that block iframes open externally. Mobile keeps the user in
|
// Apps that block iframes open externally instead of landing in a broken webview.
|
||||||
// Archipelago and shows the explicit fallback instead of leaving the shell.
|
if (mustOpenNewTab.value && appUrl.value) {
|
||||||
if (!isMobile && mustOpenNewTab.value && appUrl.value) {
|
|
||||||
window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
window.open(appUrl.value, '_blank', 'noopener,noreferrer')
|
||||||
if (isInlinePanel.value) emit('close')
|
if (isInlinePanel.value) emit('close')
|
||||||
else closeRouteSession()
|
else closeRouteSession()
|
||||||
@ -532,6 +531,12 @@ onBeforeUnmount(() => {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-session-frame-scroll-host {
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile: full-bleed app sessions — no border, no radius, no shadow */
|
/* Mobile: full-bleed app sessions — no border, no radius, no shadow */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.app-session-root {
|
.app-session-root {
|
||||||
|
|||||||
@ -106,10 +106,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- No Results -->
|
<!-- No Results -->
|
||||||
<div v-if="filteredPackageEntries.length === 0 && searchQuery" class="text-center py-12">
|
<div v-if="filteredPackageEntries.length === 0 && marketplaceMatches.length === 0 && searchQuery" class="text-center py-12">
|
||||||
<p class="text-white/70">{{ t('apps.noResults', { query: searchQuery }) }}</p>
|
<p class="text-white/70">{{ t('apps.noResults', { query: searchQuery }) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="marketplaceMatches.length > 0" class="mb-5">
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<span class="discover-terminal-tag">app store</span>
|
||||||
|
<h2 class="text-lg font-bold text-white">Available in Discover</h2>
|
||||||
|
<div class="flex-1 h-px bg-white/10"></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<button
|
||||||
|
v-for="app in marketplaceMatches"
|
||||||
|
:key="app.id"
|
||||||
|
type="button"
|
||||||
|
class="glass-card p-4 text-left flex items-center gap-3 hover:bg-orange-500/5 hover:border-orange-500/15 transition-colors"
|
||||||
|
@click="openMarketplaceResult(app)"
|
||||||
|
>
|
||||||
|
<img v-if="app.icon" :src="app.icon" :alt="app.title" class="w-12 h-12 rounded-xl object-cover bg-white/10" />
|
||||||
|
<div v-else class="w-12 h-12 rounded-xl bg-white/10 flex-shrink-0"></div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="font-semibold text-white truncate">{{ app.title }}</p>
|
||||||
|
<p class="text-xs text-white/50 truncate">Available in App Store</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Mobile: iPhone-style icon grid -->
|
<!-- Mobile: iPhone-style icon grid -->
|
||||||
<div class="md:hidden">
|
<div class="md:hidden">
|
||||||
<AppIconGrid
|
<AppIconGrid
|
||||||
@ -236,10 +260,12 @@ import AppCard from './apps/AppCard.vue'
|
|||||||
import AppIconGrid from './apps/AppIconGrid.vue'
|
import AppIconGrid from './apps/AppIconGrid.vue'
|
||||||
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
|
||||||
import { useAppsActions } from './apps/useAppsActions'
|
import { useAppsActions } from './apps/useAppsActions'
|
||||||
|
import { useMarketplaceApp } from '@/composables/useMarketplaceApp'
|
||||||
import {
|
import {
|
||||||
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
|
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
|
||||||
WEB_ONLY_APPS, WEB_ONLY_APP_URLS, buildAllCategories, useCategoriesWithApps,
|
WEB_ONLY_APPS, WEB_ONLY_APP_URLS, buildAllCategories, useCategoriesWithApps,
|
||||||
} from './apps/appsConfig'
|
} from './apps/appsConfig'
|
||||||
|
import { getCuratedAppList, INSTALLED_ALIASES, type MarketplaceApp } from './marketplace/marketplaceData'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -247,6 +273,7 @@ const route = useRoute()
|
|||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const serverStore = useServerStore()
|
const serverStore = useServerStore()
|
||||||
const actions = useAppsActions()
|
const actions = useAppsActions()
|
||||||
|
const { setCurrentApp } = useMarketplaceApp()
|
||||||
const showSideload = ref(false)
|
const showSideload = ref(false)
|
||||||
const sideloading = ref(false)
|
const sideloading = ref(false)
|
||||||
const sideloadError = ref('')
|
const sideloadError = ref('')
|
||||||
@ -266,6 +293,10 @@ const activeTab = ref<AppsTab>(
|
|||||||
route.query.tab === 'websites' || route.query.tab === 'services' ? 'websites' : 'apps'
|
route.query.tab === 'websites' || route.query.tab === 'services' ? 'websites' : 'apps'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(() => route.query.tab, (tab) => {
|
||||||
|
activeTab.value = tab === 'websites' || tab === 'services' ? 'websites' : 'apps'
|
||||||
|
})
|
||||||
|
|
||||||
// Search (debounced)
|
// Search (debounced)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const debouncedSearchQuery = ref('')
|
const debouncedSearchQuery = ref('')
|
||||||
@ -309,6 +340,19 @@ const packages = computed(() => {
|
|||||||
|
|
||||||
const categoriesWithApps = useCategoriesWithApps(packages, ALL_CATEGORIES)
|
const categoriesWithApps = useCategoriesWithApps(packages, ALL_CATEGORIES)
|
||||||
|
|
||||||
|
const curatedApps = getCuratedAppList()
|
||||||
|
const marketplaceMatches = computed(() => {
|
||||||
|
const q = debouncedSearchQuery.value.trim().toLowerCase()
|
||||||
|
if (!q || activeTab.value !== 'apps') return [] as MarketplaceApp[]
|
||||||
|
return curatedApps.filter(app => {
|
||||||
|
if (isInstalledInMyApps(app.id)) return false
|
||||||
|
return app.title?.toLowerCase().includes(q) ||
|
||||||
|
app.id.toLowerCase().includes(q) ||
|
||||||
|
app.author?.toLowerCase().includes(q) ||
|
||||||
|
(typeof app.description === 'string' && app.description.toLowerCase().includes(q))
|
||||||
|
}).slice(0, 6)
|
||||||
|
})
|
||||||
|
|
||||||
const isLoadingApps = computed(() => !store.hasLoadedInitialData && !connectionError.value)
|
const isLoadingApps = computed(() => !store.hasLoadedInitialData && !connectionError.value)
|
||||||
|
|
||||||
// Connection error state
|
// Connection error state
|
||||||
@ -352,6 +396,17 @@ const filteredPackageEntries = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function isInstalledInMyApps(appId: string): boolean {
|
||||||
|
if (appId in packages.value) return true
|
||||||
|
const aliases = INSTALLED_ALIASES[appId]
|
||||||
|
return aliases ? aliases.some(alias => alias in packages.value) : false
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMarketplaceResult(app: MarketplaceApp) {
|
||||||
|
setCurrentApp(app)
|
||||||
|
router.push({ name: 'marketplace-app-detail', params: { id: app.id }, query: { from: 'apps' } }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
// Uninstall modal
|
// Uninstall modal
|
||||||
const uninstallModal = ref({ show: false, appId: '', appTitle: '' })
|
const uninstallModal = ref({ show: false, appId: '', appTitle: '' })
|
||||||
|
|
||||||
|
|||||||
@ -35,6 +35,10 @@
|
|||||||
|
|
||||||
<!-- Mobile: search -->
|
<!-- Mobile: search -->
|
||||||
<div class="md:hidden mb-4">
|
<div class="md:hidden mb-4">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="discover-terminal-tag">discover</span>
|
||||||
|
<h1 class="text-lg font-bold text-white">App Store</h1>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
@ -312,6 +316,9 @@ const categoriesWithApps = computed(() => {
|
|||||||
|
|
||||||
const filteredApps = computed(() => {
|
const filteredApps = computed(() => {
|
||||||
let apps = allApps.value
|
let apps = allApps.value
|
||||||
|
if (selectedCategory.value && selectedCategory.value !== 'all' && !searchQuery.value) {
|
||||||
|
apps = apps.filter(app => app.category === selectedCategory.value)
|
||||||
|
}
|
||||||
if (searchQuery.value) {
|
if (searchQuery.value) {
|
||||||
const query = searchQuery.value.toLowerCase()
|
const query = searchQuery.value.toLowerCase()
|
||||||
apps = apps.filter(app =>
|
apps = apps.filter(app =>
|
||||||
|
|||||||
@ -37,6 +37,10 @@
|
|||||||
|
|
||||||
<!-- Mobile: search (tabs handled by Dashboard.vue header) -->
|
<!-- Mobile: search (tabs handled by Dashboard.vue header) -->
|
||||||
<div class="md:hidden mb-4">
|
<div class="md:hidden mb-4">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="discover-terminal-tag">discover</span>
|
||||||
|
<h1 class="text-lg font-bold text-white">App Store</h1>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
@ -258,7 +262,7 @@ const categoriesWithApps = computed(() => {
|
|||||||
const filteredApps = computed(() => {
|
const filteredApps = computed(() => {
|
||||||
let apps = allApps.value
|
let apps = allApps.value
|
||||||
|
|
||||||
if (selectedCategory.value && selectedCategory.value !== 'all') {
|
if (selectedCategory.value && selectedCategory.value !== 'all' && !searchQuery.value) {
|
||||||
apps = apps.filter(app => app.category === selectedCategory.value)
|
apps = apps.filter(app => app.category === selectedCategory.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,16 +9,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<iframe
|
<div v-if="appUrl && !iframeBlocked" class="absolute inset-0 app-session-frame-scroll-host">
|
||||||
v-if="appUrl && !iframeBlocked"
|
<iframe
|
||||||
ref="iframeRef"
|
ref="iframeRef"
|
||||||
:key="refreshKey"
|
:key="refreshKey"
|
||||||
:src="appUrl"
|
:src="appUrl"
|
||||||
class="absolute inset-0 w-full h-full border-0 iframe-scrollbar-hide"
|
class="w-full h-full border-0 iframe-scrollbar-hide"
|
||||||
title="App content"
|
title="App content"
|
||||||
@load="$emit('iframeLoad')"
|
tabindex="0"
|
||||||
@error="$emit('iframeError')"
|
@load="$emit('iframeLoad')"
|
||||||
/>
|
@error="$emit('iframeError')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Iframe blocked fallback -->
|
<!-- Iframe blocked fallback -->
|
||||||
<Transition name="content-fade">
|
<Transition name="content-fade">
|
||||||
@ -69,9 +71,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { nextTick, ref, watch } from 'vue'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
appUrl: string
|
appUrl: string
|
||||||
appId: string
|
appId: string
|
||||||
appTitle: string
|
appTitle: string
|
||||||
@ -91,5 +93,11 @@ defineEmits<{
|
|||||||
|
|
||||||
const iframeRef = ref<HTMLIFrameElement | null>(null)
|
const iframeRef = ref<HTMLIFrameElement | null>(null)
|
||||||
|
|
||||||
|
watch(() => [props.appUrl, props.refreshKey, props.iframeBlocked], async () => {
|
||||||
|
if (!props.appUrl || props.iframeBlocked) return
|
||||||
|
await nextTick()
|
||||||
|
iframeRef.value?.focus({ preventScroll: true })
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
defineExpose({ iframeRef })
|
defineExpose({ iframeRef })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -61,7 +61,7 @@ describe('AppIconGrid', () => {
|
|||||||
expect(useAppLauncherStore().panelAppId).toBe('lnd')
|
expect(useAppLauncherStore().panelAppId).toBe('lnd')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('opens desktop new-tab apps through app session on mobile', async () => {
|
it('routes desktop new-tab apps through app session on mobile', async () => {
|
||||||
Object.defineProperty(window, 'innerWidth', {
|
Object.defineProperty(window, 'innerWidth', {
|
||||||
value: 390,
|
value: 390,
|
||||||
writable: true,
|
writable: true,
|
||||||
@ -78,5 +78,6 @@ describe('AppIconGrid', () => {
|
|||||||
await wrapper.get('.app-icon-item').trigger('click')
|
await wrapper.get('.app-icon-item').trigger('click')
|
||||||
|
|
||||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||||
|
expect(useAppLauncherStore().panelAppId).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
to="/dashboard/apps?tab=websites"
|
to="/dashboard/apps?tab=websites"
|
||||||
class="mode-switcher-btn"
|
class="mode-switcher-btn"
|
||||||
:class="{ 'mode-switcher-btn-active': route.query.tab === 'services' || route.query.tab === 'websites' }"
|
:class="{ 'mode-switcher-btn-active': route.query.tab === 'services' || route.query.tab === 'websites' }"
|
||||||
|
@click.prevent="router.push({ path: '/dashboard/apps', query: { tab: 'websites' } })"
|
||||||
>Websites</RouterLink>
|
>Websites</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,8 +4,7 @@
|
|||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<button
|
<button
|
||||||
@click="showFilter = true"
|
@click="showFilter = true"
|
||||||
class="md:hidden fixed right-4 z-40 w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl mobile-back-btn"
|
class="md:hidden fixed right-4 z-[2400] w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl mobile-filter-btn"
|
||||||
style="left: auto;"
|
|
||||||
>
|
>
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||||
@ -17,10 +16,10 @@
|
|||||||
<Transition name="modal">
|
<Transition name="modal">
|
||||||
<div
|
<div
|
||||||
v-if="showFilter"
|
v-if="showFilter"
|
||||||
class="fixed inset-0 z-50 flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
|
class="fixed inset-0 z-[3000] flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
|
||||||
@click.self="closeFilter"
|
@click.self="closeFilter"
|
||||||
>
|
>
|
||||||
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto">
|
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto mobile-filter-sheet">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h2 class="text-2xl font-bold text-white">Filter</h2>
|
<h2 class="text-2xl font-bold text-white">Filter</h2>
|
||||||
<button @click="closeFilter" class="text-white/60 hover:text-white transition-colors">
|
<button @click="closeFilter" class="text-white/60 hover:text-white transition-colors">
|
||||||
|
|||||||
@ -3,8 +3,7 @@
|
|||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<button
|
<button
|
||||||
@click="showModal = true"
|
@click="showModal = true"
|
||||||
class="md:hidden fixed right-4 z-40 w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl mobile-back-btn"
|
class="md:hidden fixed right-4 z-[2400] w-14 h-14 rounded-full glass-button flex items-center justify-center shadow-2xl mobile-filter-btn"
|
||||||
style="left: auto;"
|
|
||||||
>
|
>
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||||
@ -16,10 +15,10 @@
|
|||||||
<Transition name="modal">
|
<Transition name="modal">
|
||||||
<div
|
<div
|
||||||
v-if="showModal"
|
v-if="showModal"
|
||||||
class="fixed inset-0 z-50 flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
|
class="fixed inset-0 z-[3000] flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
|
||||||
@click.self="close()"
|
@click.self="close()"
|
||||||
>
|
>
|
||||||
<div ref="modalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto">
|
<div ref="modalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto mobile-filter-sheet">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h2 class="text-2xl font-bold text-white">{{ t('marketplace.filterByCategory') }}</h2>
|
<h2 class="text-2xl font-bold text-white">{{ t('marketplace.filterByCategory') }}</h2>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user