Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 27 additions & 42 deletions pages/local-histories/hongkong.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
<template>
<div class="flex w-full flex-col justify-center">
<section class="hk-local-histories-hero w-full min-h-screen flex justify-center relative text-white px-12 py-24 laptop:py-36">
<UButton
class="absolute z-10 top-4 left-4"
icon="i-material-symbols-arrow-back-rounded"
:to="localeRoute({ name: 'local-histories' })"
variant="ghost"
color="neutral"
size="md"
:aria-label="$t('common_back')"
:ui="{ base: 'text-white hover:bg-white/10' }"
/>
<div class="z-10 flex flex-col text-center max-w-6xl mx-auto px-2 laptop:px-12">
<div class="absolute bottom-[60px] left-1/2 transform -translate-x-1/2 w-full max-w-2xl text-white">
<div class="flex justify-center mb-6">
Expand Down Expand Up @@ -315,13 +325,14 @@ const featuredByRegion = computed(() => {
})

const searchTerm = ref('')
const debouncedSearchTerm = refDebounced(searchTerm, 300)
const activeKeyword = ref('全部')

const featuredTags = ['全部', '社區', '文化', '民生', '環境', '歷史']

const filteredFeatured = computed(() => {
const keyword = activeKeyword.value
const term = searchTerm.value.trim().toLowerCase()
const term = debouncedSearchTerm.value.trim().toLowerCase()

return featuredHKLocalHistories.filter((item) => {
const matchesKeyword = keyword === '全部' || item.tags.includes(keyword)
Expand Down Expand Up @@ -361,48 +372,22 @@ const heroStats = computed(() => [
])

const heroStatsRef = ref<HTMLElement | null>(null)
const animatedStats = ref<number[]>(heroStatTargets.map(() => 0))
const hasAnimatedStats = ref(false)
let rafHandle = 0
let statsObserver: IntersectionObserver | null = null

const animateStats = () => {
const duration = 700
const start = performance.now()

const step = (now: number) => {
const progress = Math.min((now - start) / duration, 1)
animatedStats.value = heroStatTargets.map(target => Math.round(target * progress))

if (progress < 1) {
rafHandle = requestAnimationFrame(step)
const statsProgress = ref(0)
const transitionedProgress = useTransition(statsProgress, { duration: 700 })
const animatedStats = computed(() =>
heroStatTargets.map(target => Math.round(target * transitionedProgress.value)),
)

const { stop: stopObserver } = useIntersectionObserver(
heroStatsRef,
([entry]) => {
if (entry?.isIntersecting) {
statsProgress.value = 1
stopObserver()
}
}

rafHandle = requestAnimationFrame(step)
}

onMounted(() => {
if (!heroStatsRef.value) return

statsObserver = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting && !hasAnimatedStats.value) {
hasAnimatedStats.value = true
animateStats()
statsObserver?.disconnect()
}
},
{ threshold: 0.4 },
)

statsObserver.observe(heroStatsRef.value)
})

onUnmounted(() => {
cancelAnimationFrame(rafHandle)
statsObserver?.disconnect()
})
},
{ threshold: 0.4 },
)

const regions = [
{
Expand Down
44 changes: 44 additions & 0 deletions pages/local-histories/index.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
<template>
<div class="flex min-h-screen w-full flex-col">
<section class="local-histories-index-hero relative flex w-full items-end justify-center px-6 pb-16 pt-32 laptop:pb-24 laptop:pt-48">
<UButton
class="absolute z-10 top-4 left-4"
icon="i-material-symbols-arrow-back-rounded"
:to="localeRoute({ name: 'store' })"
variant="ghost"
color="neutral"
size="md"
:aria-label="$t('common_back')"
:ui="{ base: 'text-white hover:bg-white/10' }"
/>
<div class="z-10 mx-auto max-w-3xl text-center text-white">
<h1
class="text-4xl font-bold laptop:text-6xl"
Expand All @@ -25,6 +35,18 @@
class="absolute inset-0 transition-transform duration-500 group-hover:scale-105"
:class="region.bgClass"
/>
<ClientOnly>
<div class="map-preview pointer-events-none absolute inset-0 flex items-center justify-center transition-transform duration-500 group-hover:scale-105">
<LazyLocalHistoriesMap
v-if="region.routeName === 'local-histories-taiwan'"
class="h-full w-full"
/>
<LazyHKLocalHistoriesMap
v-else-if="region.routeName === 'local-histories-hongkong'"
class="h-full w-full"
/>
</div>
</ClientOnly>
<div
class="relative flex flex-col justify-end p-8 laptop:p-10"
:class="region.minH"
Expand Down Expand Up @@ -105,4 +127,26 @@ const regions = [
.bg-hk-card {
background: linear-gradient(160deg, #8a4a2a 0%, #b85c38 40%, #d4a574 100%);
}

.map-preview {
opacity: 0.3;
}

.map-preview :deep(path) {
fill: white !important;
stroke: rgba(255, 255, 255, 0.5) !important;
cursor: default !important;
will-change: auto !important;
}

.map-preview :deep(circle) {
fill: white !important;
stroke: white !important;
}

.map-preview :deep(#features),
.map-preview :deep(#pins) {
will-change: auto !important;
transition: none !important;
}
</style>
78 changes: 33 additions & 45 deletions pages/local-histories/taiwan.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
<template>
<div class="flex w-full flex-col justify-center">
<section class="local-histories-hero w-full min-h-screen flex justify-center relative text-white px-12 py-24 laptop:py-36">
<UButton
class="absolute z-10 top-4 left-4"
icon="i-material-symbols-arrow-back-rounded"
:to="localeRoute({ name: 'local-histories' })"
variant="ghost"
color="neutral"
size="md"
:aria-label="$t('common_back')"
:ui="{ base: 'text-white hover:bg-white/10' }"
/>
<div class="z-10 flex flex-col text-center max-w-6xl mx-auto px-2 laptop:px-12">
<div class="absolute bottom-[60px] left-1/2 transform -translate-x-1/2 w-full max-w-2xl text-white">
<div class="flex justify-center mb-6">
Expand Down Expand Up @@ -60,7 +70,7 @@
<header class="mb-8 laptop:mb-12">
<h2
class="text-2xl text-center font-semibold text-neutral-900"
v-text="$t('local_histories_overview_title') "
v-text="$t('local_histories_overview_title')"
/>
<p
class="mt-2 text-sm text-center text-neutral-600"
Expand All @@ -86,6 +96,8 @@
role="button"
tabindex="0"
@click="handleCardClick(region.key)"
@keydown.enter="handleCardClick(region.key)"
@keydown.space.prevent="handleCardClick(region.key)"
>
<div
class="pointer-events-none absolute inset-0 rounded-2xl bg-cover bg-center opacity-30"
Expand All @@ -105,7 +117,7 @@
v-text="region.areas.join('、')"
/>
<div
v-if="expandedRegion === region.key"
v-if="selectedRegion === region.key"
class="mt-4 border-t border-[#d8dfd2] pt-3"
Comment on lines 117 to 121
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The region cards are interactive (role/button + click), but there’s no keyboard activation handler for Enter/Space in this component. Since this section is part of the expandable card content, please add key handlers on the card container so keyboard users can expand/collapse regions.

Copilot uses AI. Check for mistakes.
>
<ul class="mt-2 grid gap-2 text-sm text-[#5c6f61]">
Expand Down Expand Up @@ -272,7 +284,6 @@ const localeRoute = useLocaleRoute()

const hoveredRegion = ref<string | null>(null)
const selectedRegion = ref<string | null>('north')
const expandedRegion = ref<string | null>('north')

const activeRegion = computed(() => selectedRegion.value ?? hoveredRegion.value)

Expand All @@ -283,13 +294,10 @@ const handleMapHover = (region: string | null) => {
const handleMapClick = (region: string) => {
if (selectedRegion.value === region) return
selectedRegion.value = region
expandedRegion.value = region
}

const handleCardClick = (regionKey: string) => {
if (selectedRegion.value === regionKey) return
selectedRegion.value = regionKey
expandedRegion.value = regionKey
selectedRegion.value = selectedRegion.value === regionKey ? null : regionKey
}

const featuredByRegion = computed(() => {
Expand All @@ -308,17 +316,16 @@ const featuredByRegion = computed(() => {
})

const searchTerm = ref('')
const debouncedSearchTerm = refDebounced(searchTerm, 300)
const activeKeyword = ref('全部')

const featuredTags = ['全部', '文化', '歷史', '飲食', '職人', '社區營造']

const featuredItems = featuredLocalHistories

const filteredFeatured = computed(() => {
const keyword = activeKeyword.value
const term = searchTerm.value.trim().toLowerCase()
const term = debouncedSearchTerm.value.trim().toLowerCase()

const filtered = featuredItems.filter((item) => {
const filtered = featuredLocalHistories.filter((item) => {
const matchesKeyword = keyword === '全部' || item.tags.includes(keyword)
if (!term) return matchesKeyword

Expand Down Expand Up @@ -358,41 +365,22 @@ const heroStats = computed(() => [
])

const heroStatsRef = ref<HTMLElement | null>(null)
const animatedStats = ref<number[]>(heroStatTargets.map(() => 0))
const hasAnimatedStats = ref(false)

const animateStats = () => {
const duration = 700
const start = performance.now()

const step = (now: number) => {
const progress = Math.min((now - start) / duration, 1)
animatedStats.value = heroStatTargets.map(target => Math.round(target * progress))

if (progress < 1) {
requestAnimationFrame(step)
const statsProgress = ref(0)
const transitionedProgress = useTransition(statsProgress, { duration: 700 })
const animatedStats = computed(() =>
heroStatTargets.map(target => Math.round(target * transitionedProgress.value)),
)

const { stop: stopObserver } = useIntersectionObserver(
heroStatsRef,
([entry]) => {
if (entry?.isIntersecting) {
statsProgress.value = 1
stopObserver()
}
}

requestAnimationFrame(step)
}

onMounted(() => {
if (!heroStatsRef.value) return

const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting && !hasAnimatedStats.value) {
hasAnimatedStats.value = true
animateStats()
observer.disconnect()
}
},
{ threshold: 0.4 },
)

observer.observe(heroStatsRef.value)
})
},
{ threshold: 0.4 },
)

const regions = [
{
Expand Down
Loading