From c9735e6fe70bbd53ab2cea80601bc7f6cf2c89e2 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Mon, 17 Aug 2026 05:00:29 +0200 Subject: [PATCH] ENH - modernize Local Pages with Sky UI Migrate Local Pages navigation and scrolling to shared Sky UI primitives, refine the profile and compose actions, and add like/save feedback. Correct the composer safe-area offset and cover the layout with focused contract tests. --- .../views/apps/LocalPagesApp.contract.test.ts | 60 +++ frontend/src/views/apps/LocalPagesApp.vue | 459 ++++++++++-------- 2 files changed, 309 insertions(+), 210 deletions(-) create mode 100644 frontend/src/views/apps/LocalPagesApp.contract.test.ts diff --git a/frontend/src/views/apps/LocalPagesApp.contract.test.ts b/frontend/src/views/apps/LocalPagesApp.contract.test.ts new file mode 100644 index 0000000..5837b0a --- /dev/null +++ b/frontend/src/views/apps/LocalPagesApp.contract.test.ts @@ -0,0 +1,60 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const source = readFileSync( + new URL('./LocalPagesApp.vue', import.meta.url), + 'utf8', +) + +describe('LocalPagesApp Sky UI contract', () => { + it('uses the current Sky UI navigation and scroll primitives', () => { + expect(source).not.toContain("from 'konsta/vue'") + expect(source).not.toContain('SkyTabBar') + expect(source).not.toContain('SkyTabButton') + expect(source).not.toContain('SkyToolbarPane') + expect(source).toContain(' { + expect(source).toMatch( + /\.pages-hero-glass\s*\{[^}]*background:\s*linear-gradient[^}]*color:\s*#fff/s, + ) + expect(source).toMatch(/\.pages__hero\s*\{[^}]*color:\s*#fff/s) + }) + + it('animates successful likes and saves with reduced motion support', () => { + expect(source).toContain('function triggerReactionPulse(') + expect(source).toContain('triggerReactionPulse(post.id, kind)') + expect(source).toContain('triggerReactionPulse(selected.value.id, kind)') + expect(source).toContain('pages-reaction-pop') + expect(source).toContain('pages-reaction-glow') + expect(source).toContain('@media (prefers-reduced-motion: reduce)') + }) + + it('gives the composer more top clearance and compact navbar actions', () => { + expect(source).toMatch( + /\.pages-create-navbar\s*\{[^}]*--sky-safe-area-top:\s*112px/s, + ) + expect(source).toMatch(/\.pages__compose-scroll\s*\{[^}]*top:\s*170px/s) + expect(source).toMatch( + /\.pages-create-navbar :deep\(\.sky-navbar__left\),[\s\S]*?background:\s*transparent;[\s\S]*?backdrop-filter:\s*none;/, + ) + expect(source).toMatch( + /\.pages-create-close,[\s\S]*?min-width:\s*44px[\s\S]*?height:\s*30px[\s\S]*?background:\s*transparent/, + ) + expect(source).toMatch(/class="pages-create-close"[\s\S]*? { + expect(source).toContain('class="pages__navbar-logout"') + expect(source).toMatch(/clear[\s\S]*?class="pages__navbar-logout"/) + expect(source).toContain(':aria-label="phone.t(\'Common.signOut\')"') + expect(source).not.toContain('class="pages__logout-glass"') + expect(source).not.toContain('class="pages__logout"') + }) +}) diff --git a/frontend/src/views/apps/LocalPagesApp.vue b/frontend/src/views/apps/LocalPagesApp.vue index 5e371a4..fadaf69 100644 --- a/frontend/src/views/apps/LocalPagesApp.vue +++ b/frontend/src/views/apps/LocalPagesApp.vue @@ -21,16 +21,17 @@ import { X, } from 'lucide-vue-next' import { + SkyButton, SkyGlass, - SkyIcon, SkyNavbar, SkyAppPage, + SkyPillNavigation, SkySearchbar, - SkyTabBar, - SkyTabButton, - SkyToolbarPane, + SkyScrollArea, + SkySegmented, + SkySegmentedButton, } from '@/ui' -import { computed, onMounted, ref } from 'vue' +import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue' @@ -85,6 +86,7 @@ const search = ref('') const category = ref('all') const feedback = ref('') const reactionPending = ref(false) +const reactionPulse = ref('') const onboardingReady = ref(false) const authMode = ref<'login' | 'register'>('login') const authUsername = ref('') @@ -180,6 +182,25 @@ const canSaveProfile = computed(() => { profileDraft.value.bio.trim().length <= 160 ) }) +const tabIndex = computed(() => (tab.value === 'feed' ? 0 : 2)) + +let reactionPulseTimer: ReturnType | undefined + +function triggerReactionPulse(id: string, kind: 'like' | 'save'): void { + reactionPulse.value = '' + void nextTick(() => { + reactionPulse.value = `${id}:${kind}` + if (reactionPulseTimer) clearTimeout(reactionPulseTimer) + reactionPulseTimer = setTimeout(() => { + reactionPulse.value = '' + reactionPulseTimer = undefined + }, 520) + }) +} + +onBeforeUnmount(() => { + if (reactionPulseTimer) clearTimeout(reactionPulseTimer) +}) const profileAvatarUrl = computed( () => selectedProfilePhoto.value?.url ?? @@ -565,6 +586,7 @@ async function react(kind: 'like' | 'save'): Promise { ) selected.value.is_liked = active } else selected.value.is_saved = active + if (active) triggerReactionPulse(selected.value.id, kind) } } @@ -581,7 +603,8 @@ async function reactToPost( kind === 'like' ? !Boolean(post.is_liked) : !Boolean(post.is_saved) reactionPending.value = true try { - await pages.react(post.id, kind, active) + const success = await pages.react(post.id, kind, active) + if (success && active) triggerReactionPulse(post.id, kind) } finally { reactionPending.value = false } @@ -753,11 +776,26 @@ onMounted(async () => { tab === 'feed' ? 'Apps.localPages.name' : 'Apps.localPages.profile', ) " - /> + > + + -
{ - -
- -
-
- - - - + + {{ phone.t('Apps.localPages.myPosts') }} + + + {{ phone.t('Apps.localPages.saved') }} + + + @@ -1077,7 +1108,11 @@ onMounted(async () => {
-
+ - - - + - - - - + {{ phone.t('Apps.localPages.discover') }} + + - - - - + {{ phone.t('Apps.localPages.create') }} + + - - - - - + + {{ phone.t('Apps.localPages.profile') }} + + +
@@ -1206,7 +1227,11 @@ onMounted(async () => { v-else component="button" type="button" - class="pages__detail-control" + class="pages__detail-control pages__reaction" + :class="{ + active: selected.is_saved, + 'is-pulsing': reactionPulse === `${selected.id}:save`, + }" @click="react('save')" > { /> -
+ + -
- + { --ink: #15191d; --panel: #20262c; --muted: #9ba4aa; + --sky-app-accent: var(--yellow); + --sky-app-accent-soft: rgb(255 214 62 / 16%); position: absolute; inset: 0; padding: 47px 0 24px; @@ -1553,7 +1595,7 @@ onMounted(async () => { align-items: center; justify-content: space-between; background: linear-gradient(125deg, #514005, #d99a00); - color: #fff7d2; + color: #fff; box-shadow: 0 8px 22px #0003; } .pages__hero div { @@ -1579,7 +1621,7 @@ onMounted(async () => { line-height: 1.35; } .pages__hero > svg { - color: var(--yellow); + color: #fff; filter: drop-shadow(0 4px 6px #0005); } .pages__search { @@ -1783,22 +1825,18 @@ onMounted(async () => { font-size: 9px; } .pages__segmented { + --sky-segmented-strong-highlight: #fff; + width: 100%; + margin: 3px 0 10px; + border: 1px solid color-mix(in srgb, currentColor 13%, transparent); padding: 4px; - border-radius: 11px; - display: flex; - background: var(--panel); + border-radius: var(--sky-radius-pill); + background: color-mix(in srgb, var(--panel) 90%, transparent); } .pages__segmented button { - flex: 1; - padding: 8px; - border-radius: 8px; - background: none; - font-size: 10px; -} -.pages__segmented button.active { - background: var(--yellow); - color: #17191a; - font-weight: 800; + min-height: 34px; + font-size: 11px; + font-weight: 750; } .pages__tabbar { position: absolute; @@ -2175,29 +2213,6 @@ onMounted(async () => { transform: translateY(8px); opacity: 0; } -.pages__tabbar { - height: 58px; - padding: 7px 7px 0; - justify-content: space-around; - backdrop-filter: blur(18px); -} -.pages--light .pages__tabbar { - border-color: #00000012; -} -.pages__tabbar button { - width: 54px; - gap: 2px; -} -.pages__tabbar button > span { - position: relative; -} -.pages__tabbar .create span { - width: 37px; - height: 30px; - margin-top: -4px; - border-radius: 10px; - box-shadow: none; -} .pages__header { height: 64px; padding: 6px 16px 8px; @@ -2267,6 +2282,41 @@ onMounted(async () => { .pages__post-foot button:disabled { opacity: 0.55; } +.pages__reaction svg { + transform-origin: center; +} +.pages__reaction.is-pulsing svg { + animation: pages-reaction-pop 480ms cubic-bezier(0.2, 0.9, 0.25, 1.35); +} +.pages__reaction.is-pulsing { + animation: pages-reaction-glow 480ms ease-out; +} +@keyframes pages-reaction-pop { + 0%, + 100% { + transform: scale(1); + } + 38% { + transform: scale(1.48) rotate(-8deg); + } + 68% { + transform: scale(0.9) rotate(3deg); + } +} +@keyframes pages-reaction-glow { + 0% { + box-shadow: 0 0 0 0 color-mix(in srgb, currentColor 34%, transparent); + } + 100% { + box-shadow: 0 0 0 12px transparent; + } +} +@media (prefers-reduced-motion: reduce) { + .pages__reaction.is-pulsing, + .pages__reaction.is-pulsing svg { + animation: none; + } +} .pages__post-foot button svg:last-child { margin-left: 0; } @@ -2736,6 +2786,10 @@ onMounted(async () => { } .pages-hero-glass { margin-bottom: 10px; + border-color: rgb(255 255 255 / 16%); + background: linear-gradient(125deg, #514005, #d99a00); + color: #fff; + box-shadow: 0 8px 22px rgb(0 0 0 / 22%); } .pages__hero { height: 105px; @@ -2749,33 +2803,12 @@ onMounted(async () => { .pages-profile-glass { margin: 3px 0 10px; } -.pages__logout-glass { - margin: 10px 0; - border-radius: 12px; -} -.pages__logout { - width: 100%; - min-height: 40px; - display: flex; - align-items: center; - justify-content: center; - gap: 7px; - background: transparent; - color: #ff6b70; - font-size: 11px; - font-weight: 800; -} .pages__profile { margin: 0; background: transparent; } -.pages-segmented-glass { - padding: 4px; - border-radius: 13px; -} .pages__segmented { - padding: 0; - background: transparent; + color: inherit; } .pages-post-glass { overflow: hidden; @@ -2784,82 +2817,88 @@ onMounted(async () => { background: transparent; box-shadow: none; } -.pages__tab-pane { - width: 100% !important; - max-width: none; - margin-right: auto; - margin-left: auto; - flex: none; - justify-content: space-around; - gap: 2px; - padding: 0 4px; +.pages-navigation { + --sky-app-accent: var(--yellow); } -.pages__tab-label { +.pages-navigation__segments { + width: 100%; +} +.pages-navigation__button { + min-width: 0; + gap: 2px; + padding-inline: 2px; +} +.pages-navigation__button small { display: block; - max-width: 52px; + max-width: 76px; overflow: hidden; - font-size: 9.5px; - line-height: 12px; + font-size: 9px; + font-weight: 750; + line-height: 10px; text-overflow: ellipsis; white-space: nowrap; } -:global(.pages__tab-pane) { - width: 100% !important; - max-width: none; - margin-right: auto; - margin-left: auto; - flex: none; - justify-content: space-around; +.pages-navigation__create { + display: grid; + place-items: center; } -:global(.pages-tab-button) { - width: 20% !important; - min-width: 0 !important; - max-width: 58px !important; - flex: 0 0 20% !important; - padding-right: 3px !important; - padding-left: 3px !important; +.pages__navbar-logout { + --sky-app-accent: #ff6b70; + width: 34px; + height: 34px; + min-height: 34px; + border: 0; + padding: 0; + background: transparent; + box-shadow: none; } .pages-create-navbar { - --sky-safe-area-top: 46px; + --sky-safe-area-top: 112px; position: absolute; z-index: 5; top: 0; right: 0; left: 0; + background: transparent; } -.pages-create-action { - height: 44px; - border-radius: 9999px; -} -.pages-create-action--close, -.pages-create-action--publish { - min-width: 58px; -} -.pages-create-close, -.pages-create-publish { - width: 100%; - height: 44px; +.pages-create-navbar :deep(.sky-navbar__left), +.pages-create-navbar :deep(.sky-navbar__right) { padding: 0; border: 0; - appearance: none; background: transparent; - color: inherit; + box-shadow: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; } .pages-create-close, .pages-create-publish { - min-width: 58px; - padding: 0 13px; - display: grid; - place-items: center; - font-size: 12px; + width: auto; + min-width: 44px; + height: 30px; + min-height: 30px; + border: 0; + padding: 0 8px; + background: transparent; + box-shadow: none; + font-size: 10px; font-weight: 800; } +.pages-create-close { + width: 30px; + min-width: 30px; + padding: 0; + color: inherit; +} +.pages-create-publish { + --sky-app-accent: var(--yellow); + color: var(--yellow); +} .pages-create-publish:disabled { opacity: 0.38; } .pages__compose-scroll { position: absolute; - top: 104px; + top: 170px; right: 0; bottom: 0; left: 0;