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
115 changes: 115 additions & 0 deletions components/features/community-poi/CommunityPoiBluemap.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed } from '#imports'
import CommunityPoiCoordsCopy from './CommunityPoiCoordsCopy.vue'
import IconFa from '~/components/base/icons/IconFa.vue'
import { useBluemapDeepLink, useBluemapUrl } from '~/composables/useBluemap'
import type { CommunityPoiCoordinates } from '~/types/community-poi'

const props = defineProps<{
title: string
coordinates: CommunityPoiCoordinates
}>()

const { t } = useI18n()
const baseUrl = useBluemapUrl()

const deepLink = computed(() => useBluemapDeepLink({
x: props.coordinates.x,
y: props.coordinates.y,
z: props.coordinates.z,
dimension: props.coordinates.dimension
}))

const dimensionLabel = computed(() => {
const dim = props.coordinates.dimension
return dim ? t(`community_poi.dimension.${dim}`) : ''
})

const coordsLabel = computed(() => {
const c = props.coordinates
const parts = c.y !== undefined ? [c.x,
c.y,
c.z] : [c.x, c.z]
return parts.join(' / ')
})

const linkAria = computed(() => t('community_poi.bluemap.link_aria', { title: props.title }))

const wrapperClass = [
'rounded-lg border p-5', 'border-[var(--color-border)] bg-[var(--color-surface)]'
].join(' ')

const titleClass = [
'inline-flex items-center gap-2 text-base font-semibold', 'text-neutral-900 dark:text-neutral-50'
].join(' ')

const iconClass = 'h-4 w-4 text-[var(--color-brand-secondary)]'

const linkClass = [
'inline-flex items-center gap-2 rounded-md',
'bg-[var(--color-brand-secondary)] px-3 py-1.5 text-sm font-medium text-white shadow-sm',
'hover:opacity-90 focus:outline-none focus-visible:ring-2',
'focus-visible:ring-[var(--color-brand-secondary)] focus-visible:ring-offset-2',
'focus-visible:ring-offset-white dark:focus-visible:ring-offset-neutral-900'
].join(' ')

const embedNoteClass = [
'bg-neutral-50 px-3 py-2 text-xs text-neutral-600', 'dark:bg-neutral-800 dark:text-neutral-400'
].join(' ')
</script>

<template>
<section :class="wrapperClass" :aria-label="t('community_poi.bluemap.aria')">
<header class="mb-3 flex items-start justify-between gap-3">
<div>
<h3 :class="titleClass">
<IconFa :icon="['fas','map']" :class="iconClass" aria-hidden="true" />
{{ t('community_poi.bluemap.title') }}
</h3>
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
<span class="font-mono">{{ coordsLabel }}</span>
<span v-if="dimensionLabel" class="ml-1">({{ dimensionLabel }})</span>
</p>
</div>
<a
:href="deepLink"
target="_blank"
rel="noopener noreferrer external"
:class="linkClass"
:aria-label="linkAria"
>
<IconFa
:icon="['fas','arrow-up-right-from-square']"
class="h-3.5 w-3.5"
aria-hidden="true"
/>
{{ t('community_poi.bluemap.open') }}
</a>
</header>

<CommunityPoiCoordsCopy
class="mb-3"
:x="coordinates.x"
:y="coordinates.y"
:z="coordinates.z"
/>

<!-- The iframe is loaded by default so every POI lands on its
in-world position immediately. `loading="lazy"` still defers the
fetch until the section is in view, which keeps initial page
weight reasonable. -->
<div class="overflow-hidden rounded-md border border-neutral-200 dark:border-neutral-800">
<iframe
:src="deepLink"
:title="t('community_poi.bluemap.iframe_title', { title: props.title })"
class="block aspect-[16/9] w-full"
loading="lazy"
allow="fullscreen"
referrerpolicy="no-referrer"
/>
<p :class="embedNoteClass">
{{ t('community_poi.bluemap.embed_note', { host: baseUrl }) }}
</p>
</div>
</section>
</template>
141 changes: 141 additions & 0 deletions components/features/community-poi/CommunityPoiCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { computed } from '#imports'
import CommunityPoiStatusBadge from './CommunityPoiStatusBadge.vue'
import CommunityPoiCategoryBadge from './CommunityPoiCategoryBadge.vue'
import CommunityPoiProgressBar from './CommunityPoiProgressBar.vue'
import IconFa from '~/components/base/icons/IconFa.vue'
import type { CommunityPoi } from '~/types/community-poi'

const props = defineProps<{
poi: CommunityPoi
}>()

const { locale, t } = useI18n()

const href = computed(() => `/${locale.value}/community-poi/${props.poi.slug}`)
const builders = computed(() => props.poi.builders ?? [])

const buildersLabel = computed(() => {
const names = builders.value.map((b) => b.name).filter(Boolean)
if (!names.length) return ''
if (names.length <= 2) return names.join(', ')
return t('community_poi.card.builders_more', {
first: names[0],
count: names.length - 1
})
})

const galleryCount = computed(() => (props.poi.gallery ?? []).length)
const schematicCount = computed(() => (props.poi.schematics ?? []).length)
const detailAria = computed(() => t('community_poi.card.open_detail', { title: props.poi.title }))

const articleClass = [
'group flex h-full flex-col overflow-hidden rounded-xl shadow-sm',
'bg-[var(--color-surface)] ring-1 ring-[var(--color-border)]',
'transition hover:shadow-md',
'focus-within:ring-2 focus-within:ring-[var(--color-brand-secondary)]'
].join(' ')

const thumbLinkClass = [
'relative block aspect-[16/9] w-full overflow-hidden', 'bg-neutral-100 dark:bg-neutral-800 focus:outline-none'
].join(' ')

const thumbImgClass = [
'h-full w-full object-cover transition-transform duration-500',
'group-hover:scale-[1.02]',
'motion-reduce:transition-none'
].join(' ')

const placeholderClass = [
'flex h-full w-full items-center justify-center', 'text-neutral-400 dark:text-neutral-600'
].join(' ')

const footerClass = [
'mt-auto flex flex-wrap items-center justify-between gap-2 pt-2 text-xs', 'text-neutral-600 dark:text-neutral-400'
].join(' ')

const showcaseBadgeClass = [
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium',
'ring-1 ring-inset',
'bg-[color-mix(in_oklab,var(--color-brand-orange)_15%,white)]',
'text-[color-mix(in_oklab,var(--color-brand-orange)_70%,black)]',
'ring-[color-mix(in_oklab,var(--color-brand-orange)_40%,transparent)]',
'dark:bg-[color-mix(in_oklab,var(--color-brand-orange)_22%,transparent)]',
'dark:text-[color-mix(in_oklab,var(--color-brand-orange)_30%,white)]'
].join(' ')
</script>

<template>
<article :class="articleClass">
<NuxtLink :to="href" :aria-label="detailAria" :class="thumbLinkClass">
<NuxtPicture
v-if="poi.thumbnail"
:src="poi.thumbnail"
:alt="poi.thumbnailAlt || poi.title"
sizes="xs:300px sm:500px md:400px lg:500px"
width="800"
height="450"
fit="cover"
quality="75"
loading="lazy"
:img-attrs="{ class: thumbImgClass }"
format="avif,webp"
/>
<div v-else :class="placeholderClass">
<IconFa :icon="['fas','image']" class="h-10 w-10" aria-hidden="true" />
</div>
<div class="absolute left-3 top-3 flex flex-wrap items-center gap-2">
<CommunityPoiStatusBadge :status="poi.status" />
<CommunityPoiCategoryBadge v-if="poi.category" :category="poi.category" />
<span
v-if="poi.acceptsContributions === false"
:class="showcaseBadgeClass"
:title="t('community_poi.card.showcase_only')"
>
<IconFa :icon="['fas','lock']" class="h-2.5 w-2.5" aria-hidden="true" />
{{ t('community_poi.card.showcase_only') }}
</span>
</div>
</NuxtLink>

<div class="flex flex-1 flex-col gap-3 p-5">
<header>
<h3 class="text-lg font-semibold leading-snug text-neutral-900 dark:text-neutral-50">
<NuxtLink
:to="href"
class="after:absolute after:inset-0 after:content-[''] focus:outline-none"
>
{{ poi.title }}
</NuxtLink>
</h3>
<p v-if="poi.location" class="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">
{{ poi.location }}
</p>
</header>

<p class="line-clamp-3 text-sm text-neutral-700 dark:text-neutral-300">
{{ poi.summary }}
</p>

<CommunityPoiProgressBar :value="poi.progress ?? 0" size="sm" />

<footer :class="footerClass">
<span v-if="buildersLabel" class="truncate">
{{ t('community_poi.card.by') }} {{ buildersLabel }}
</span>
<span class="flex items-center gap-3">
<span v-if="galleryCount" class="inline-flex items-center gap-1">
<IconFa :icon="['fas','image']" class="h-3 w-3" aria-hidden="true" />
<span class="sr-only">{{ t('community_poi.card.gallery_count_sr') }}</span>
{{ galleryCount }}
</span>
<span v-if="schematicCount" class="inline-flex items-center gap-1">
<IconFa :icon="['fas','cube']" class="h-3 w-3" aria-hidden="true" />
<span class="sr-only">{{ t('community_poi.card.schematic_count_sr') }}</span>
{{ schematicCount }}
</span>
</span>
</footer>
</div>
</article>
</template>
62 changes: 62 additions & 0 deletions components/features/community-poi/CommunityPoiCategoryBadge.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed } from '#imports'
import type { CommunityPoiCategory } from '~/types/community-poi'

const props = defineProps<{
category: CommunityPoiCategory
}>()

const { t } = useI18n()

const label = computed(() => t(`community_poi.category.${props.category}`))

// Brand-aligned palette: team uses brand purple, community uses brand
// secondary (cyan-blue), collab uses brand accent (magenta), farm uses
// brand primary (green) since farms are typically green-themed.
const tone = computed(() => {
switch (props.category) {
case 'team':
return [
'bg-[color-mix(in_oklab,var(--color-brand-purple)_15%,white)]',
'text-[color-mix(in_oklab,var(--color-brand-purple)_75%,black)]',
'ring-[color-mix(in_oklab,var(--color-brand-purple)_40%,transparent)]',
'dark:bg-[color-mix(in_oklab,var(--color-brand-purple)_28%,transparent)]',
'dark:text-[color-mix(in_oklab,var(--color-brand-purple)_30%,white)]'
].join(' ')
case 'collab':
return [
'bg-[color-mix(in_oklab,var(--color-brand-accent)_15%,white)]',
'text-[color-mix(in_oklab,var(--color-brand-accent)_70%,black)]',
'ring-[color-mix(in_oklab,var(--color-brand-accent)_40%,transparent)]',
'dark:bg-[color-mix(in_oklab,var(--color-brand-accent)_28%,transparent)]',
'dark:text-[color-mix(in_oklab,var(--color-brand-accent)_30%,white)]'
].join(' ')
case 'farm':
return [
'bg-[color-mix(in_oklab,var(--color-brand-primary)_15%,white)]',
'text-[color-mix(in_oklab,var(--color-brand-primary)_75%,black)]',
'ring-[color-mix(in_oklab,var(--color-brand-primary)_40%,transparent)]',
'dark:bg-[color-mix(in_oklab,var(--color-brand-primary)_28%,transparent)]',
'dark:text-[color-mix(in_oklab,var(--color-brand-primary)_30%,white)]'
].join(' ')
case 'community':
default:
return [
'bg-[color-mix(in_oklab,var(--color-brand-secondary)_15%,white)]',
'text-[color-mix(in_oklab,var(--color-brand-secondary)_75%,black)]',
'ring-[color-mix(in_oklab,var(--color-brand-secondary)_40%,transparent)]',
'dark:bg-[color-mix(in_oklab,var(--color-brand-secondary)_25%,transparent)]',
'dark:text-[color-mix(in_oklab,var(--color-brand-secondary)_30%,white)]'
].join(' ')
}
})

const sizing = 'px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset'
</script>

<template>
<span :class="['inline-flex items-center gap-1.5 rounded-full', sizing, tone]">
<span class="size-1.5 rounded-full bg-current" aria-hidden="true" />
{{ label }}
</span>
</template>
Loading
Loading