Files
InvoiceShelf/resources/scripts/features/admin/modules/views/ModuleDetailView.vue
Darko Gjorgjijoski f3ab0f22fc chore(frontend): fix ESLint, add Pint+ESLint pre-commit hook, centralize v-html
Fix the broken ESLint setup: add vue-eslint-parser and @typescript-eslint/parser
and wire the TS parser into eslint.config.mjs so .ts and <script lang=ts> parse
(was failing outright). Clear the resulting backlog to a clean 0/0 baseline —
fix genuine issues, relax two intentional-pattern rules (multi-word-component-names,
no-required-prop-with-default).

Add a committed .githooks/pre-commit (enabled via core.hooksPath, auto-set by the
prepare script) that runs Pint on staged PHP and ESLint --max-warnings 0 on staged
resources/scripts JS/TS/Vue, blocking on failure. Add composer/npm lint scripts and
document the gate in CLAUDE.md.

Replace every scattered v-html with a single audited BaseSanitizedHtml component
that DOMPurify-sanitizes its input (new utils/markdown.ts sanitizeHtml), so
server/registry-provided HTML is actually sanitized and vue/no-v-html stays enabled
everywhere but one reviewed sink.
2026-06-11 11:11:12 +02:00

551 lines
19 KiB
Vue

<template>
<div v-if="isFetchingInitialData" class="p-8">
<div class="animate-pulse space-y-4">
<div class="h-8 bg-surface-tertiary rounded w-1/3" />
<div class="h-64 bg-surface-tertiary rounded" />
</div>
</div>
<BasePage v-else-if="moduleData" class="bg-surface">
<BasePageHeader :title="moduleData.name">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem :title="$t('modules.title')" to="/admin/administration/modules" />
<BaseBreadcrumbItem :title="moduleData.name" to="#" active />
</BaseBreadcrumb>
</BasePageHeader>
<!-- Hero: Info + Action -->
<div class="mt-8 lg:grid lg:grid-cols-3 lg:gap-12">
<!-- Module Info (2/3) -->
<div class="lg:col-span-2">
<div class="flex items-start gap-5">
<!-- Icon / Cover thumbnail -->
<div class="shrink-0 h-16 w-16 rounded-xl overflow-hidden bg-gradient-to-br from-primary-100 to-primary-200 flex items-center justify-center">
<img v-if="moduleData.cover" :src="moduleData.cover" alt="" class="h-full w-full object-cover" />
<BaseIcon v-else name="PuzzlePieceIcon" class="h-8 w-8 text-primary-400" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl font-bold tracking-tight text-heading">
{{ moduleData.name }}
</h1>
<span class="rounded-full bg-primary-50 px-3 py-0.5 text-xs font-semibold uppercase tracking-wide text-primary-600">
{{ moduleData.access_tier }}
</span>
</div>
<div class="mt-1.5 flex items-center gap-4 text-sm text-muted flex-wrap">
<span v-if="moduleData.author_name" class="flex items-center gap-1.5">
<img
v-if="moduleData.author_avatar"
:src="moduleData.author_avatar"
alt=""
class="h-4 w-4 rounded-full"
/>
{{ moduleData.author_name }}
</span>
<span v-if="moduleData.latest_module_version">
v{{ moduleVersion }}
</span>
<span v-if="updatedAt">
{{ updatedAt }}
</span>
</div>
</div>
</div>
<p class="mt-6 text-sm text-muted leading-relaxed">
{{ moduleData.long_description }}
</p>
</div>
<!-- Action Card (1/3) -->
<div class="mt-6 lg:mt-0">
<div class="rounded-xl border border-line-default bg-surface-secondary p-6">
<!-- Not purchased -->
<template v-if="!moduleData.purchased">
<a :href="buyLink" target="_blank">
<BaseButton size="lg" class="w-full flex items-center justify-center">
<BaseIcon name="ShoppingCartIcon" class="mr-2" />
{{ $t('modules.buy_now') }}
</BaseButton>
</a>
</template>
<!-- Purchased, not installed -->
<template v-else-if="!moduleData.installed">
<BaseButton
v-if="moduleData.latest_module_version"
variant="primary"
:loading="isInstalling"
:disabled="isInstalling"
class="w-full flex items-center justify-center"
@click="handleInstall"
>
<BaseIcon v-if="!isInstalling" name="ArrowDownTrayIcon" class="mr-2 h-4 w-4" />
{{ $t('modules.install') }} v{{ moduleData.latest_module_version }}
</BaseButton>
</template>
<!-- Installed -->
<template v-else-if="isModuleInstalled">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-1.5">
<BaseIcon name="CheckCircleIcon" class="h-4 w-4 text-green-500" />
<span class="text-sm font-medium text-heading">{{ $t('modules.installed') }}</span>
</div>
<span class="text-xs text-muted">v{{ moduleData.installed_module_version }}</span>
</div>
<div class="flex gap-2">
<BaseButton
v-if="moduleData.update_available"
variant="primary"
:loading="isInstalling"
:disabled="isInstalling"
class="flex-1 flex items-center justify-center"
@click="handleInstall"
>
<BaseIcon v-if="!isInstalling" name="ArrowPathIcon" class="mr-1.5 h-4 w-4" />
{{ $t('modules.update_to') }} {{ moduleData.latest_module_version }}
</BaseButton>
<BaseButton
v-if="moduleData.enabled"
variant="danger"
:loading="isDisabling"
:disabled="isDisabling"
:class="moduleData.update_available ? '' : 'flex-1'"
class="flex items-center justify-center"
@click="handleDisable"
>
<BaseIcon v-if="!isDisabling" name="NoSymbolIcon" class="h-4 w-4" :class="{ 'mr-1.5': !moduleData.update_available }" />
<span v-if="!moduleData.update_available">{{ $t('modules.disable') }}</span>
</BaseButton>
<BaseButton
v-else
variant="primary-outline"
:loading="isEnabling"
:disabled="isEnabling"
class="flex-1 flex items-center justify-center"
@click="handleEnable"
>
<BaseIcon v-if="!isEnabling" name="CheckIcon" class="mr-1.5 h-4 w-4" />
{{ $t('modules.enable') }}
</BaseButton>
</div>
</template>
<!-- Installation Steps -->
<ul v-if="isInstalling && installationSteps.length" class="mt-4 space-y-2">
<li
v-for="step in installationSteps"
:key="step.translationKey"
class="flex items-center justify-between text-sm py-1"
>
<span class="text-muted">{{ $t(step.translationKey) }}</span>
<span
:class="stepStatusClass(step)"
class="px-2 py-0.5 text-xs rounded-full font-medium"
>
{{ stepStatusLabel(step) }}
</span>
</li>
</ul>
<!-- Links -->
<div v-if="moduleData.links?.length" class="mt-5 pt-4 border-t border-line-light space-y-2">
<a
v-for="(link, key) in moduleData.links"
:key="key"
:href="(link as ModuleLinkItem).link"
target="_blank"
class="flex items-center gap-2 text-sm text-primary-600 hover:text-primary-700"
>
<BaseIcon :name="(link as ModuleLinkItem).icon ?? 'LinkIcon'" class="h-4 w-4" />
{{ (link as ModuleLinkItem).label }}
</a>
</div>
</div>
</div>
</div>
<!-- Highlights -->
<div v-if="moduleData.highlights?.length" class="mt-12 pt-8 border-t border-line-light">
<h3 class="text-sm font-semibold text-heading mb-4">
{{ $t('modules.what_you_get') }}
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-3">
<div
v-for="highlight in moduleData.highlights"
:key="highlight"
class="flex items-start gap-2.5 text-sm text-muted"
>
<BaseIcon name="CheckIcon" class="h-4 w-4 text-green-500 shrink-0 mt-0.5" />
{{ highlight }}
</div>
</div>
</div>
<!-- Screenshots Gallery -->
<div v-if="displayImages.length" class="mt-12 pt-8 border-t border-line-light">
<h3 class="text-sm font-semibold text-heading mb-5">
{{ $t('modules.screenshots') }}
</h3>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Video thumbnail -->
<button
v-if="thumbnail && videoUrl"
class="relative rounded-lg overflow-hidden bg-surface-tertiary aspect-video group"
type="button"
@click="setDisplayVideo"
>
<img :src="thumbnail" alt="" class="w-full h-full object-cover" />
<div class="absolute inset-0 flex items-center justify-center bg-black/20 group-hover:bg-black/30 transition-colors">
<BaseIcon name="PlayIcon" class="h-12 w-12 text-white" />
</div>
</button>
<!-- Screenshots -->
<button
v-for="(screenshot, ssIdx) in displayImages"
:key="ssIdx"
class="rounded-lg overflow-hidden bg-surface-tertiary aspect-video"
type="button"
@click="setDisplayImage(screenshot.url)"
>
<img :src="screenshot.url" alt="" class="w-full h-full object-cover" />
</button>
</div>
<!-- Lightbox-style expanded view -->
<div
v-if="displayVideo || expandedImage"
class="mt-4 rounded-lg overflow-hidden bg-surface-tertiary"
>
<div v-if="displayVideo" class="aspect-video">
<iframe
:src="videoUrl ?? ''"
class="w-full h-full"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
/>
</div>
<div v-else-if="expandedImage" class="relative">
<img :src="expandedImage" alt="" class="w-full" />
<button
class="absolute top-3 right-3 rounded-full bg-black/50 hover:bg-black/70 p-1.5 text-white transition-colors"
@click="expandedImage = null"
>
<BaseIcon name="XMarkIcon" class="h-5 w-5" />
</button>
</div>
</div>
</div>
<!-- Tabs: Reviews, FAQ, License -->
<div class="mt-12 pt-8 border-t border-line-light">
<div class="-mb-px flex space-x-8 border-b border-line-default">
<button
v-for="tab in tabs"
:key="tab.key"
:class="[
activeTab === tab.key
? 'border-primary-600 text-primary-600'
: 'border-transparent text-body hover:text-heading hover:border-line-strong',
'whitespace-nowrap py-4 border-b-2 font-medium text-sm',
]"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</div>
<!-- Reviews -->
<div v-if="activeTab === 'reviews'" class="py-6">
<div v-if="moduleData.reviews?.length">
<div
v-for="(review, reviewIdx) in moduleData.reviews"
:key="reviewIdx"
class="flex text-sm text-muted space-x-4"
>
<div class="flex-none py-4">
<span class="inline-flex items-center justify-center h-10 w-10 rounded-full bg-surface-secondary">
<span class="text-sm font-medium leading-none text-muted uppercase">
{{ review.user?.[0] ?? '?' }}
</span>
</span>
</div>
<div :class="[reviewIdx === 0 ? '' : 'border-t border-line-default', 'py-4']">
<h3 class="font-medium text-heading">{{ review.user }}</h3>
<p>{{ formatDate(review.created_at) }}</p>
<div class="flex items-center mt-2">
<BaseRating :rating="review.rating" />
</div>
<BaseSanitizedHtml class="mt-2 prose prose-sm max-w-none text-muted" :html="review.comment" />
</div>
</div>
</div>
<p v-else class="text-muted text-sm">{{ $t('modules.no_reviews_found') }}</p>
</div>
<!-- FAQ -->
<dl v-if="activeTab === 'faq'" class="py-6 text-sm text-muted space-y-6">
<div v-for="faq in moduleData.faq" :key="faq.question">
<dt class="font-medium text-heading">{{ faq.question }}</dt>
<dd class="mt-1">{{ faq.answer }}</dd>
</div>
</dl>
<!-- License -->
<div v-if="activeTab === 'license'" class="py-6">
<BaseSanitizedHtml class="prose prose-sm max-w-none text-muted" :html="moduleData.license" />
</div>
</div>
<!-- Other Modules -->
<div v-if="otherModules?.length" class="mt-16">
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium text-heading">{{ $t('modules.other_modules') }}</h2>
<a
href="/admin/administration/modules"
class="whitespace-nowrap text-sm font-medium text-primary-600 hover:text-primary-500"
>
{{ $t('modules.view_all') }}
<span aria-hidden="true"> &rarr;</span>
</a>
</div>
<div class="mt-6 grid grid-cols-1 gap-x-8 gap-y-8 sm:grid-cols-2 lg:grid-cols-4">
<div v-for="(other, moduleIdx) in otherModules" :key="moduleIdx">
<ModuleCard :data="other" />
</div>
</div>
</div>
<div class="p-6" />
</BasePage>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useModuleStore } from '../store'
import type { InstallationStep } from '../store'
import ModuleCard from '../components/ModuleCard.vue'
import { useDialogStore } from '../../../../stores/dialog.store'
import type { Module, ModuleLink } from '../../../../types/domain/module'
interface ModuleLinkItem {
icon: string
link: string
label: string
}
interface TabItem {
key: string
label: string
}
const moduleStore = useModuleStore()
const dialogStore = useDialogStore()
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const isFetchingInitialData = ref<boolean>(true)
const isInstalling = ref<boolean>(false)
const isEnabling = ref<boolean>(false)
const isDisabling = ref<boolean>(false)
const displayVideo = ref<boolean>(false)
const expandedImage = ref<string | null>(null)
const thumbnail = ref<string | null>(null)
const videoUrl = ref<string | null>(null)
const activeTab = ref<string>('reviews')
const installationSteps = reactive<InstallationStep[]>([])
const tabs = computed<TabItem[]>(() => [
{ key: 'reviews', label: t('modules.customer_reviews') },
{ key: 'faq', label: t('modules.faq') },
{ key: 'license', label: t('modules.license') },
])
const moduleData = computed<Module | undefined>(() => {
return moduleStore.currentModule?.data
})
const otherModules = computed<Module[] | undefined>(() => {
return moduleStore.currentModule?.meta?.modules
})
const averageRating = computed<number>(() => {
return parseInt(String(moduleData.value?.average_rating ?? 0), 10)
})
const isModuleInstalled = computed<boolean>(() => {
return !!(moduleData.value?.installed && moduleData.value?.latest_module_version)
})
const moduleVersion = computed<string>(() => {
return moduleData.value?.installed_module_version ?? moduleData.value?.latest_module_version ?? ''
})
const updatedAt = computed<string>(() => {
const date =
moduleData.value?.installed_module_version_updated_at ??
moduleData.value?.latest_module_version_updated_at
return date ? formatDate(date) : ''
})
const displayImages = computed<Array<{ url: string }>>(() => {
const images: Array<{ url: string }> = []
if (moduleData.value?.cover) {
images.push({ url: moduleData.value.cover })
}
if (moduleData.value?.screenshots) {
moduleData.value.screenshots.forEach((s) => images.push({ url: s.url }))
}
return images
})
const buyLink = computed<string>(() => {
return `/modules/${moduleData.value?.slug ?? ''}`
})
watch(() => route.params.slug, () => {
loadData()
})
onMounted(() => {
loadData()
})
async function loadData(): Promise<void> {
const slug = route.params.slug as string
if (!slug) return
isFetchingInitialData.value = true
await moduleStore.fetchModule(slug)
videoUrl.value = moduleData.value?.video_link ?? null
thumbnail.value = moduleData.value?.video_thumbnail ?? null
displayVideo.value = false
expandedImage.value = null
isFetchingInitialData.value = false
}
async function handleInstall(): Promise<void> {
if (!moduleData.value) return
installationSteps.length = 0
isInstalling.value = true
const success = await moduleStore.installModule(
{
slug: moduleData.value.slug,
module_name: moduleData.value.module_name,
version: moduleData.value.latest_module_version,
checksum_sha256: moduleData.value.latest_module_checksum_sha256,
},
(step) => {
const existing = installationSteps.find(
(s) => s.translationKey === step.translationKey,
)
if (existing) {
Object.assign(existing, step)
} else {
installationSteps.push({ ...step })
}
},
)
isInstalling.value = false
if (success) {
setTimeout(() => location.reload(), 1500)
}
}
function handleDisable(): void {
if (!moduleData.value) return
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('modules.disable_warning'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async (res: boolean) => {
if (res) {
isDisabling.value = true
const response = await moduleStore.disableModule(moduleData.value!.module_name)
isDisabling.value = false
if (response.success) {
setTimeout(() => location.reload(), 1500)
}
}
})
}
async function handleEnable(): Promise<void> {
if (!moduleData.value) return
isEnabling.value = true
const res = await moduleStore.enableModule(moduleData.value.module_name)
isEnabling.value = false
if (res.success) {
setTimeout(() => location.reload(), 1500)
}
}
function setDisplayImage(url: string): void {
displayVideo.value = false
expandedImage.value = url
}
function setDisplayVideo(): void {
displayVideo.value = true
expandedImage.value = null
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
function stepStatusClass(step: InstallationStep): string {
const status = stepStatusLabel(step)
switch (status) {
case 'pending':
return 'text-primary-800 bg-surface-muted'
case 'finished':
return 'text-teal-500 bg-teal-100'
case 'running':
return 'text-blue-400 bg-blue-100'
default:
return 'text-danger bg-red-200'
}
}
function stepStatusLabel(step: InstallationStep): string {
if (step.started && step.completed) return 'finished'
if (step.started && !step.completed) return 'running'
return 'pending'
}
</script>