Files
sky_phone/frontend/src/views/apps/AppStoreDetail.vue
T
2026-08-16 19:22:56 +02:00

780 lines
19 KiB
Vue

<script setup lang="ts">
import {
Activity,
BarChart3,
ChevronLeft,
ChevronRight,
Share2,
Sparkles,
Star,
Users,
} from 'lucide-vue-next'
import { computed, ref } from 'vue'
import { getPhoneAppLabel, isExternalPhoneApp } from '@/config/apps'
import { usePhoneStore } from '@/stores/phone'
import type { LaunchablePhoneAppDefinition } from '@/types/apps'
import AppStoreAction from './AppStoreAction.vue'
const props = defineProps<{
action: 'get' | 'installing' | 'open'
app: LaunchablePhoneAppDefinition
}>()
const emit = defineEmits<{
action: []
back: []
share: []
}>()
const phone = usePhoneStore()
const previews = ref<HTMLElement | null>(null)
const activePreviewIndex = ref(0)
const previewCount = 3
const appName = computed(() => getPhoneAppLabel(props.app, phone.t))
const developer = computed(() =>
isExternalPhoneApp(props.app)
? props.app.developer
: phone.t('Apps.appStore.details.skyStudios'),
)
const rating = computed(() => (4.4 + (props.app.gridOrder % 5) / 10).toFixed(1))
const ratingCount = computed(() =>
new Intl.NumberFormat(phone.lang, { notation: 'compact' }).format(
12400 + props.app.gridOrder * 1371,
),
)
const ageRating = computed(() =>
props.app.category === 'games' ? '12+' : '4+',
)
const chartRank = computed(() => `#${(props.app.gridOrder % 8) + 1}`)
const version = computed(
() => `1.${(props.app.gridOrder % 9) + 1}.${props.app.gridOrder % 5}`,
)
const detailStyle = computed(() => {
const palettes = [
['#2365d8', '#69c7ff'],
['#7a42c2', '#d19dff'],
['#0b8a78', '#60e2c0'],
['#cf5b35', '#ffc06b'],
['#4057b3', '#9baaff'],
] as const
const palette = palettes[props.app.gridOrder % palettes.length]!
return {
'--store-detail-accent': palette[0],
'--store-detail-glow': palette[1],
}
})
function scrollToPreview(index: number): void {
const row = previews.value
if (!row) return
const cards = Array.from(
row.querySelectorAll<HTMLElement>('.store-detail-preview'),
)
const nextIndex = Math.min(cards.length - 1, Math.max(0, index))
const card = cards[nextIndex]
if (!card) return
activePreviewIndex.value = nextIndex
row.scrollTo({
behavior: 'smooth',
left: card.offsetLeft - row.offsetLeft,
})
}
function updateActivePreview(): void {
const row = previews.value
if (!row) return
const cards = Array.from(
row.querySelectorAll<HTMLElement>('.store-detail-preview'),
)
activePreviewIndex.value = cards.reduce((closestIndex, card, index) => {
const closest = cards[closestIndex]
if (!closest) return index
const distance = Math.abs(card.offsetLeft - row.offsetLeft - row.scrollLeft)
const closestDistance = Math.abs(
closest.offsetLeft - row.offsetLeft - row.scrollLeft,
)
return distance < closestDistance ? index : closestIndex
}, 0)
}
</script>
<template>
<section class="store-detail" :style="detailStyle">
<header class="store-detail__toolbar">
<button
type="button"
:aria-label="phone.t('Common.back')"
@click="emit('back')"
>
<ChevronLeft :size="26" :stroke-width="2.2" aria-hidden="true" />
</button>
<button
type="button"
:aria-label="phone.t('Apps.appStore.details.share')"
@click="emit('share')"
>
<Share2 :size="21" :stroke-width="2" aria-hidden="true" />
</button>
</header>
<section class="store-detail__hero">
<img
class="phone-effect--filtered-media phone-effect--expensive-shadow"
:src="app.iconImage"
alt=""
draggable="false"
/>
<div>
<h1>{{ appName }}</h1>
<p>{{ developer }}</p>
<button
type="button"
class="store-detail__action"
:class="{
'store-detail__action--icon': action === 'installing',
'store-detail__action--get': action === 'get',
}"
:disabled="action === 'installing'"
@click="emit('action')"
>
<AppStoreAction :action="action" />
</button>
</div>
</section>
<dl class="store-detail__facts">
<div>
<dt>
{{ phone.t('Apps.appStore.details.ratings', { count: ratingCount }) }}
</dt>
<dd>{{ rating }}</dd>
<span class="store-detail__stars" aria-hidden="true">
<Star v-for="star in 5" :key="star" :size="11" fill="currentColor" />
</span>
</div>
<div>
<dt>{{ phone.t('Apps.appStore.details.age') }}</dt>
<dd>{{ ageRating }}</dd>
<span>{{ phone.t('Apps.appStore.details.years') }}</span>
</div>
<div>
<dt>{{ phone.t('Apps.appStore.details.chart') }}</dt>
<dd>{{ chartRank }}</dd>
<span>{{ phone.t(`Home.groups.${app.category}`) }}</span>
</div>
</dl>
<section class="store-detail__whats-new">
<header>
<h2>{{ phone.t('Apps.appStore.details.whatsNew') }}</h2>
<ChevronRight :size="22" :stroke-width="2.4" aria-hidden="true" />
</header>
<div class="store-detail__version">
<span>{{ phone.t('Apps.appStore.details.version', { version }) }}</span>
<span>{{ phone.t('Apps.appStore.details.updatedToday') }}</span>
</div>
<p>
{{
phone.t('Apps.appStore.details.releaseNotes', {
app: appName,
})
}}
</p>
</section>
<section class="store-detail__preview-section">
<header>
<h2>{{ phone.t('Apps.appStore.details.preview') }}</h2>
<div class="store-detail__preview-navigation">
<span>{{ activePreviewIndex + 1 }} / {{ previewCount }}</span>
<button
type="button"
:aria-label="phone.t('Apps.appStore.details.previousPreview')"
:disabled="activePreviewIndex === 0"
@click="scrollToPreview(activePreviewIndex - 1)"
>
<ChevronLeft :size="17" :stroke-width="2.4" aria-hidden="true" />
</button>
<button
type="button"
:aria-label="phone.t('Apps.appStore.details.nextPreview')"
:disabled="activePreviewIndex === previewCount - 1"
@click="scrollToPreview(activePreviewIndex + 1)"
>
<ChevronRight :size="17" :stroke-width="2.4" aria-hidden="true" />
</button>
</div>
</header>
<div
ref="previews"
class="store-detail__previews"
@scroll.passive="updateActivePreview"
>
<article
class="store-detail-preview store-detail-preview--overview phone-effect--expensive-shadow"
>
<div class="store-detail-preview__status">
<span>{{ phone.t('Apps.appStore.details.previewLive') }}</span>
<Activity :size="14" aria-hidden="true" />
</div>
<img :src="app.iconImage" alt="" draggable="false" />
<strong>{{ appName }}</strong>
<small>{{ phone.t('Apps.appStore.details.previewOverview') }}</small>
<div class="store-detail-preview__metrics">
<span><b>24</b>{{ phone.t('Apps.appStore.details.today') }}</span>
<span
><b>87%</b>{{ phone.t('Apps.appStore.details.progress') }}</span
>
</div>
<div class="store-detail-preview__activity">
<span></span><span></span><span></span><span></span><span></span>
</div>
</article>
<article
class="store-detail-preview store-detail-preview--community phone-effect--expensive-shadow"
>
<div class="store-detail-preview__status">
<span>{{ phone.t('Apps.appStore.details.previewCommunity') }}</span>
<Users :size="14" aria-hidden="true" />
</div>
<div class="store-detail-preview__avatars">
<span>AM</span><span>JS</span><span>RK</span>
</div>
<strong>{{
phone.t('Apps.appStore.details.communityTitle', { app: appName })
}}</strong>
<small>{{ phone.t('Apps.appStore.details.communityBody') }}</small>
<div class="store-detail-preview__message">
<Sparkles :size="15" aria-hidden="true" />
<span>{{ phone.t('Apps.appStore.details.featuredMoment') }}</span>
</div>
<img :src="app.iconImage" alt="" draggable="false" />
</article>
<article
class="store-detail-preview store-detail-preview--insights phone-effect--expensive-shadow"
>
<div class="store-detail-preview__status">
<span>{{ phone.t('Apps.appStore.details.previewInsights') }}</span>
<BarChart3 :size="14" aria-hidden="true" />
</div>
<strong>{{ phone.t('Apps.appStore.details.weeklyActivity') }}</strong>
<div class="store-detail-preview__chart" aria-hidden="true">
<span
v-for="height in [38, 64, 48, 82, 56, 92, 73]"
:key="height"
:style="{ height: `${height}%` }"
></span>
</div>
<div class="store-detail-preview__insight">
<b>+18%</b>
<span>{{ phone.t('Apps.appStore.details.fromLastWeek') }}</span>
</div>
<img :src="app.iconImage" alt="" draggable="false" />
</article>
</div>
</section>
<section class="store-detail__description">
<h2>{{ phone.t('Apps.appStore.details.about') }}</h2>
<p>
{{
phone.t('Apps.appStore.details.description', {
app: appName,
category: phone.t(`Home.groups.${app.category}`),
})
}}
</p>
</section>
</section>
</template>
<style scoped>
.store-detail {
width: 100%;
min-width: 0;
display: grid;
overflow-x: hidden;
gap: var(--sky-space-5);
padding: var(--sky-space-3) 0 var(--sky-space-6);
color: var(--sky-text);
}
.store-detail__preview-section {
min-width: 0;
}
.store-detail__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
}
.store-detail__toolbar button {
width: var(--sky-touch-target);
height: var(--sky-touch-target);
display: grid;
place-items: center;
border: 1px solid var(--sky-hairline);
border-radius: 50%;
color: var(--sky-text);
background: var(--sky-surface-variant);
transition:
background-color 100ms ease,
border-color 100ms ease,
box-shadow 100ms ease,
transform 100ms ease;
}
.store-detail__toolbar button:active {
transform: scale(0.94);
}
.store-detail__hero {
display: grid;
grid-template-columns: 116px minmax(0, 1fr);
align-items: center;
gap: var(--sky-space-4);
}
.store-detail__hero > img {
width: 116px;
height: 116px;
border-radius: calc(var(--sky-radius-card) + var(--sky-space-1));
object-fit: cover;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.2);
}
.store-detail__hero > div {
min-width: 0;
}
.store-detail__hero h1 {
margin: 0;
overflow: hidden;
color: var(--sky-text);
font-size: 24px;
line-height: 1.02;
text-overflow: ellipsis;
}
.store-detail__hero p {
margin: 6px 0 12px;
overflow: hidden;
color: var(--sky-muted);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-detail__hero button {
min-width: 72px;
min-height: var(--sky-touch-target);
display: grid;
place-items: center;
border: 0;
border-radius: var(--sky-radius-pill);
padding: 0 var(--sky-space-4);
color: #fff;
background: var(--sky-app-accent);
font-size: 12px;
font-weight: 850;
}
.store-detail__hero .store-detail__action--icon {
width: var(--sky-touch-target);
min-width: var(--sky-touch-target);
padding: 0;
color: var(--sky-app-accent);
background: transparent;
}
.store-detail__hero .store-detail__action--get {
min-width: 54px;
min-height: 30px;
height: 30px;
padding: 0 10px;
font-size: 10px;
}
.store-detail__facts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin: 0;
border-top: 1px solid var(--sky-hairline);
border-bottom: 1px solid var(--sky-hairline);
padding: var(--sky-space-3) 0;
}
.store-detail__facts > div {
min-width: 0;
display: flex;
align-items: center;
flex-direction: column;
border-right: 1px solid var(--sky-hairline);
text-align: center;
}
.store-detail__facts > div:last-child {
border-right: 0;
}
.store-detail__facts dt {
overflow: hidden;
color: var(--sky-muted);
font-size: 9px;
font-weight: 800;
letter-spacing: 0.04em;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.store-detail__facts dd {
margin: 5px 0 0;
color: var(--sky-text);
font-size: 24px;
font-weight: 700;
}
.store-detail__facts span {
max-width: 100%;
overflow: hidden;
color: var(--sky-muted);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-detail__facts .store-detail__stars {
display: flex;
color: var(--sky-muted);
}
.store-detail__whats-new,
.store-detail__description {
border-bottom: 1px solid var(--sky-hairline);
padding-bottom: var(--sky-space-5);
}
.store-detail__whats-new header,
.store-detail__preview-section > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sky-space-2);
}
.store-detail__whats-new h2,
.store-detail__preview-section h2,
.store-detail__description h2 {
margin: 0;
font-size: var(--sky-font-medium-title);
}
.store-detail__version {
display: flex;
justify-content: space-between;
gap: var(--sky-space-3);
margin-top: var(--sky-space-3);
color: var(--sky-muted);
font-size: 11px;
}
.store-detail__whats-new p,
.store-detail__description p {
margin: var(--sky-space-3) 0 0;
color: var(--sky-text);
font-size: 13px;
line-height: 1.45;
}
.store-detail__preview-navigation {
display: flex;
align-items: center;
gap: 5px;
}
.store-detail__preview-navigation > span {
min-width: 30px;
color: var(--sky-muted);
font-size: 9px;
font-weight: 700;
text-align: center;
}
.store-detail__preview-navigation button {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border: 1px solid var(--sky-hairline);
border-radius: 50%;
padding: 0;
color: var(--sky-text);
background: var(--sky-surface-variant);
transition:
background-color 100ms ease,
color 100ms ease,
transform 100ms ease;
}
.store-detail__preview-navigation button:disabled {
opacity: 0.34;
}
.store-detail__preview-navigation button:active:not(:disabled) {
transform: scale(0.92);
}
.store-detail__previews {
display: flex;
gap: var(--sky-space-3);
margin-right: calc(0px - var(--sky-page-gutter));
overflow-x: auto;
padding: var(--sky-space-3) var(--sky-page-gutter) var(--sky-space-2) 0;
scroll-snap-type: x mandatory;
scrollbar-width: none;
}
.store-detail__previews::-webkit-scrollbar {
display: none;
}
.store-detail-preview {
width: 224px;
height: 354px;
position: relative;
display: flex;
overflow: hidden;
flex: 0 0 224px;
flex-direction: column;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: var(--sky-radius-card);
padding: var(--sky-space-4);
color: #fff;
background:
radial-gradient(
circle at 78% 12%,
var(--store-detail-glow),
transparent 32%
),
linear-gradient(155deg, var(--store-detail-accent), #101523 118%);
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.18);
scroll-snap-align: start;
}
.store-detail-preview::after {
position: absolute;
inset: 0;
background: repeating-linear-gradient(
-35deg,
transparent 0 20px,
rgba(255, 255, 255, 0.025) 21px 22px
);
content: '';
pointer-events: none;
}
.store-detail-preview__status {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
color: rgba(255, 255, 255, 0.72);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.store-detail-preview--overview > img {
width: 90px;
height: 90px;
z-index: 1;
align-self: center;
margin: 26px 0 16px;
border-radius: 24px;
object-fit: cover;
box-shadow: 0 14px 24px rgba(0, 0, 0, 0.28);
transform: rotate(-4deg);
}
.store-detail-preview > strong,
.store-detail-preview > small {
position: relative;
z-index: 1;
}
.store-detail-preview > strong {
font-size: 20px;
line-height: 1.08;
}
.store-detail-preview > small {
margin-top: 5px;
color: rgba(255, 255, 255, 0.68);
font-size: 11px;
}
.store-detail-preview__metrics {
z-index: 1;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--sky-space-2);
margin-top: auto;
}
.store-detail-preview__metrics span {
display: flex;
flex-direction: column;
border-radius: var(--sky-radius-control);
padding: var(--sky-space-3);
background: rgba(255, 255, 255, 0.11);
color: rgba(255, 255, 255, 0.7);
font-size: 9px;
}
.store-detail-preview__metrics b {
color: #fff;
font-size: 17px;
}
.store-detail-preview__activity {
z-index: 1;
height: 28px;
display: flex;
align-items: flex-end;
gap: 5px;
margin-top: var(--sky-space-3);
}
.store-detail-preview__activity span {
width: 20%;
border-radius: var(--sky-radius-pill);
background: rgba(255, 255, 255, 0.42);
}
.store-detail-preview__activity span:nth-child(1) {
height: 42%;
}
.store-detail-preview__activity span:nth-child(2) {
height: 72%;
}
.store-detail-preview__activity span:nth-child(3) {
height: 55%;
}
.store-detail-preview__activity span:nth-child(4) {
height: 92%;
}
.store-detail-preview__activity span:nth-child(5) {
height: 68%;
}
.store-detail-preview__avatars {
z-index: 1;
display: flex;
margin: 42px 0 22px;
}
.store-detail-preview__avatars span {
width: 54px;
height: 54px;
display: grid;
place-items: center;
margin-right: -12px;
border: 3px solid rgba(255, 255, 255, 0.82);
border-radius: 50%;
background: rgba(17, 22, 38, 0.74);
font-size: 12px;
font-weight: 800;
}
.store-detail-preview__message {
z-index: 1;
display: flex;
align-items: center;
gap: var(--sky-space-2);
margin-top: auto;
border-radius: var(--sky-radius-control);
padding: var(--sky-space-3);
background: rgba(255, 255, 255, 0.12);
font-size: 10px;
}
.store-detail-preview--community > img,
.store-detail-preview--insights > img {
width: 42px;
height: 42px;
z-index: 1;
position: absolute;
right: var(--sky-space-4);
bottom: var(--sky-space-4);
border-radius: 12px;
object-fit: cover;
box-shadow: 0 8px 14px rgba(0, 0, 0, 0.24);
}
.store-detail-preview--insights > strong {
margin-top: 36px;
}
.store-detail-preview__chart {
height: 145px;
z-index: 1;
display: flex;
align-items: flex-end;
gap: 7px;
margin-top: var(--sky-space-5);
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
padding: 0 var(--sky-space-2);
}
.store-detail-preview__chart span {
width: 14px;
border-radius: 7px 7px 0 0;
background: linear-gradient(180deg, #fff, rgba(255, 255, 255, 0.24));
}
.store-detail-preview__insight {
z-index: 1;
display: flex;
flex-direction: column;
margin-top: var(--sky-space-4);
}
.store-detail-preview__insight b {
font-size: 22px;
}
.store-detail-preview__insight span {
color: rgba(255, 255, 255, 0.68);
font-size: 10px;
}
@media (hover: hover) {
.store-detail__toolbar button:hover {
border-color: rgba(255, 255, 255, 0.16);
background: var(--sky-surface-tint);
box-shadow: 0 7px 16px rgba(0, 0, 0, 0.18);
transform: translateY(-1px);
}
.store-detail__preview-navigation button:hover:not(:disabled) {
color: var(--sky-app-accent);
background: var(--sky-surface-tint);
transform: translateY(-1px);
}
}
@media (prefers-reduced-motion: reduce) {
.store-detail__previews {
scroll-behavior: auto;
}
}
</style>