Files
Starlight_Lancher/apps/app-frontend/src/pages/project/Gallery.vue
Disy920 fa96226beb
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Deploy telemetry dashboard / deploy (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
refactor: remove telemetry and fix launcher workflows
2026-09-13 20:32:57 +08:00

364 lines
9.6 KiB
Vue

<script setup lang="ts">
import {
CalendarIcon,
ContractIcon,
ExpandIcon,
ExternalIcon,
LeftArrowIcon,
RightArrowIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
commonProjectSettingsMessages,
defineMessages,
NewModal,
useFormatDateTime,
useVIntl,
} from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import {
type ProjectGalleryCaptionField,
projectGalleryTranslationSegmentId,
visibleProjectGallery,
} from '@/helpers/project-gallery'
import type { TranslationMode, TranslationStyle } from '@/helpers/translation'
interface GalleryImage {
url: string
raw_url?: string
title?: string
description?: string
created: string
}
interface GalleryEntry {
image: GalleryImage
index: number
}
const props = withDefaults(
defineProps<{
project: {
id: string
gallery?: GalleryImage[]
}
translationActive?: boolean
translations?: Record<string, string>
translationMode?: TranslationMode
translationStyle?: TranslationStyle
}>(),
{
translationActive: false,
translations: () => ({}),
translationMode: 'bilingual',
translationStyle: 'weakened',
},
)
const { formatMessage } = useVIntl()
const formatDate = useFormatDateTime({
year: 'numeric',
month: 'long',
day: 'numeric',
})
const screenshotMessages = defineMessages({
zoomIn: {
id: 'app.instance.screenshots.zoom-in',
defaultMessage: 'View at full size',
},
zoomOut: {
id: 'app.instance.screenshots.zoom-out',
defaultMessage: 'Fit to window',
},
})
const filteredGallery = computed<GalleryEntry[]>(() => visibleProjectGallery(props.project.gallery))
const selectedGalleryItem = ref<GalleryEntry | null>(null)
const zoomedIn = ref(false)
const viewerModal = ref<InstanceType<typeof NewModal>>()
const viewerTitle = computed(() => {
if (!selectedGalleryItem.value) return formatMessage(commonProjectSettingsMessages.gallery)
return (
galleryText(selectedGalleryItem.value, 'title') ||
formatMessage(commonProjectSettingsMessages.gallery)
)
})
const viewerImageUrl = computed(() => {
if (!selectedGalleryItem.value) return ''
return zoomedIn.value
? (selectedGalleryItem.value.image.raw_url ?? selectedGalleryItem.value.image.url)
: selectedGalleryItem.value.image.url
})
const translationClass = computed(() => [
'gallery-translation',
`gallery-translation--${props.translationStyle}`,
])
function translationFor(
entry: GalleryEntry,
field: ProjectGalleryCaptionField,
): string | undefined {
if (!props.translationActive) return undefined
return (
props.translations[projectGalleryTranslationSegmentId(entry.index, field)]?.trim() || undefined
)
}
function galleryText(entry: GalleryEntry, field: ProjectGalleryCaptionField): string {
const original = entry.image[field] ?? ''
const translated = translationFor(entry, field)
return props.translationMode === 'translation-only' && translated ? translated : original
}
function showBilingualTranslation(entry: GalleryEntry, field: ProjectGalleryCaptionField): boolean {
return props.translationMode === 'bilingual' && !!translationFor(entry, field)
}
function imageAlt(entry: GalleryEntry): string {
return galleryText(entry, 'title') || formatMessage(commonProjectSettingsMessages.gallery)
}
function viewImage(entry: GalleryEntry) {
selectedGalleryItem.value = entry
zoomedIn.value = false
viewerModal.value?.show()
}
function changeImage(offset: number) {
if (!selectedGalleryItem.value || filteredGallery.value.length < 2) return
const currentIndex = filteredGallery.value.findIndex(
(entry) => entry.index === selectedGalleryItem.value?.index,
)
const nextIndex =
(currentIndex + offset + filteredGallery.value.length) % filteredGallery.value.length
selectedGalleryItem.value = filteredGallery.value[nextIndex]
zoomedIn.value = false
}
function handleViewerHide() {
selectedGalleryItem.value = null
zoomedIn.value = false
}
function keyListener(event: KeyboardEvent) {
if (!selectedGalleryItem.value) return
if (event.key === 'ArrowLeft') {
event.preventDefault()
changeImage(-1)
} else if (event.key === 'ArrowRight') {
event.preventDefault()
changeImage(1)
}
}
onMounted(() => {
window.addEventListener('keydown', keyListener)
})
onUnmounted(() => {
window.removeEventListener('keydown', keyListener)
})
</script>
<template>
<div class="grid grid-cols-[repeat(auto-fill,minmax(17rem,1fr))] gap-4">
<article
v-for="entry in filteredGallery"
:key="entry.image.url"
class="group overflow-hidden rounded-2xl border border-solid border-surface-5 bg-surface-2 transition-colors hover:border-brand"
>
<button
class="relative block aspect-video w-full cursor-zoom-in overflow-hidden border-0 bg-surface-1 p-0"
:aria-label="formatMessage(commonMessages.viewLabel)"
@click="viewImage(entry)"
>
<img
:src="entry.image.url"
:alt="imageAlt(entry)"
class="size-full object-cover transition-transform duration-200 group-hover:scale-[1.02]"
/>
</button>
<div class="flex min-h-0 flex-1 flex-col gap-2 p-3">
<div
v-if="galleryText(entry, 'title') || galleryText(entry, 'description')"
class="min-w-0"
>
<h3
v-if="galleryText(entry, 'title')"
class="m-0 break-words font-semibold text-contrast"
>
{{ galleryText(entry, 'title') }}
</h3>
<p v-if="showBilingualTranslation(entry, 'title')" :class="translationClass">
{{ translationFor(entry, 'title') }}
</p>
<p v-if="galleryText(entry, 'description')" class="mb-0 mt-1 break-words text-secondary">
{{ galleryText(entry, 'description') }}
</p>
<p v-if="showBilingualTranslation(entry, 'description')" :class="translationClass">
{{ translationFor(entry, 'description') }}
</p>
</div>
<div class="mt-auto flex items-center gap-2 text-sm text-secondary">
<CalendarIcon class="size-4 shrink-0" aria-hidden="true" />
{{ formatDate(new Date(entry.image.created)) }}
</div>
</div>
</article>
</div>
<NewModal
ref="viewerModal"
:max-width="'92rem'"
:width="'calc(100vw - 4rem)'"
:no-padding="true"
:header="viewerTitle"
:on-hide="handleViewerHide"
>
<div
v-if="selectedGalleryItem"
class="relative flex w-full min-h-64 max-h-[calc(100vh-13rem)] items-center justify-center overflow-hidden bg-surface-1"
>
<div
class="min-w-0 flex max-h-[calc(100vh-13rem)] w-full items-center justify-center overflow-auto p-4"
>
<img
:src="viewerImageUrl"
:alt="imageAlt(selectedGalleryItem)"
:class="
zoomedIn
? 'max-w-none cursor-zoom-out'
: 'max-h-[calc(100vh-15rem)] max-w-full cursor-zoom-in'
"
@click="zoomedIn = !zoomedIn"
/>
</div>
<ButtonStyled v-if="filteredGallery.length > 1" circular>
<button
class="absolute left-4 top-1/2 -translate-y-1/2"
:aria-label="formatMessage(commonMessages.backButton)"
@click="changeImage(-1)"
>
<LeftArrowIcon />
</button>
</ButtonStyled>
<ButtonStyled v-if="filteredGallery.length > 1" circular>
<button
class="absolute right-4 top-1/2 -translate-y-1/2"
:aria-label="formatMessage(commonMessages.nextButton)"
@click="changeImage(1)"
>
<RightArrowIcon />
</button>
</ButtonStyled>
</div>
<template #actions>
<div v-if="selectedGalleryItem" class="flex flex-wrap items-center justify-between gap-2">
<div class="min-w-0 text-sm text-secondary">
<p v-if="galleryText(selectedGalleryItem, 'description')" class="m-0 break-words">
{{ galleryText(selectedGalleryItem, 'description') }}
</p>
<p
v-if="showBilingualTranslation(selectedGalleryItem, 'description')"
:class="translationClass"
>
{{ translationFor(selectedGalleryItem, 'description') }}
</p>
<div class="mt-1 flex items-center gap-2">
<CalendarIcon class="size-4 shrink-0" aria-hidden="true" />
{{ formatDate(new Date(selectedGalleryItem.image.created)) }}
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<ButtonStyled>
<button @click="zoomedIn = !zoomedIn">
<ContractIcon v-if="zoomedIn" />
<ExpandIcon v-else />
{{ formatMessage(zoomedIn ? screenshotMessages.zoomOut : screenshotMessages.zoomIn) }}
</button>
</ButtonStyled>
<ButtonStyled>
<a
target="_blank"
rel="noreferrer"
:href="selectedGalleryItem.image.raw_url ?? selectedGalleryItem.image.url"
>
<ExternalIcon />
{{ formatMessage(commonMessages.openInBrowserButton) }}
</a>
</ButtonStyled>
</div>
</div>
</template>
</NewModal>
</template>
<style scoped>
.gallery-translation {
margin: 0.25rem 0 0;
animation: translation-float-in 0.5s ease-out both;
}
.gallery-translation--weakened {
color: var(--color-secondary);
}
.gallery-translation--blur {
filter: blur(4px);
opacity: 0.75;
transition:
filter 0.1s ease-in-out,
opacity 0.1s ease-in-out;
}
.gallery-translation--blur:hover {
filter: blur(0);
opacity: 1;
}
.gallery-translation--blockquote {
padding: 4px 0 4px 8px;
border-left: 4px solid var(--color-brand);
}
.gallery-translation--dashed-line {
text-decoration: underline dashed var(--color-brand);
text-underline-offset: 5px;
}
.gallery-translation--border {
padding: 2px 4px;
border: 1px solid var(--color-brand);
border-radius: 4px;
}
.gallery-translation--text-color {
color: oklch(0.693 0.17 162.48);
}
.gallery-translation--background {
padding: 2px 4px;
border-radius: 4px;
background-color: color-mix(in srgb, var(--color-brand) 15%, transparent);
}
@keyframes translation-float-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>